diff --git a/Sources/ColumbaApp/Services/PropagationNodeManager.swift b/Sources/ColumbaApp/Services/PropagationNodeManager.swift index 42a84a2..51fbd53 100644 --- a/Sources/ColumbaApp/Services/PropagationNodeManager.swift +++ b/Sources/ColumbaApp/Services/PropagationNodeManager.swift @@ -314,7 +314,21 @@ public final class PropagationNodeManager { /// Trigger an immediate sync from the propagation node. /// /// If no propagation node is selected yet, auto-selects the best available node first. - public func syncNow() async { + /// - Parameter userInitiated: `true` when the user explicitly triggered the sync (a + /// refresh button, pull-to-refresh, or a Sync Now action) — i.e. the cases that may + /// present the status sheet. Only these reset the displayed transfer state up front + /// (see below). Background, periodic, and on-foreground auto-syncs pass `false`. + public func syncNow(userInitiated: Bool = false) async { + // For user-initiated syncs only, reset transfer state up front so a freshly-opened + // status sheet shows THIS sync's progress from a clean "connecting" slate, not the + // previous run's stale "Download complete / N new messages". Background / periodic + // / on-foreground syncs skip this so they never clobber a status sheet the user may + // have left open on a prior result. Early-return guards below overwrite this with + // the appropriate terminal state (e.g. .noPath). + if userInitiated { + syncState = PropagationTransferState(state: .linking) + } + // Model B: the LXMF router lives in the NE — the app can't sync in-process. // Ensure a PN is selected + its config is in the seam, then fire the sync-now // Darwin trigger. Real progress arrives back via the sync-state channel diff --git a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift index 404c7ca..dc3e37b 100644 --- a/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift +++ b/Sources/ColumbaApp/ViewModels/SettingsViewModel.swift @@ -719,7 +719,7 @@ public final class SettingsViewModel { isSyncing = true syncError = nil - await propManager.syncNow() + await propManager.syncNow(userInitiated: true) syncProgress = propManager.syncState.progress lastSyncTime = propManager.lastSyncTime diff --git a/Sources/ColumbaApp/Views/Chats/ChatsView.swift b/Sources/ColumbaApp/Views/Chats/ChatsView.swift index e6d59e9..5d021fe 100644 --- a/Sources/ColumbaApp/Views/Chats/ChatsView.swift +++ b/Sources/ColumbaApp/Views/Chats/ChatsView.swift @@ -45,7 +45,8 @@ struct ChatsView: View { /// Conversation pending deletion (confirmation alert). @State private var deletingConversation: Conversation? - /// Controls the propagation-sync status sheet (auto-shown while a sync is active). + /// Controls the propagation-sync status sheet. Presented only for user-initiated + /// syncs (tapping the refresh button); background / periodic syncs run silently. @State private var isSyncSheetPresented: Bool = false // MARK: - Theme Colors @@ -97,9 +98,11 @@ struct ChatsView: View { // Refresh button — syncs from propagation node then reloads DB Button { guard viewModel?.isRefreshing != true else { return } + // User explicitly asked to sync → surface the status sheet. + isSyncSheetPresented = true Task { viewModel?.isRefreshing = true - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.refreshConversations() viewModel?.isRefreshing = false } @@ -166,14 +169,16 @@ struct ChatsView: View { } message: { Text("This will permanently delete the conversation and all its messages.") } - // Auto-show the sync status sheet while a propagation sync is active (manual - // Sync Now / pull-to-refresh, or — under Model B — an NE-driven periodic sync). - // The sheet stays up through the terminal phase so the user sees the result. - .onChange(of: appServices.propagationManager?.syncState.isSyncing ?? false) { _, active in - if active { isSyncSheetPresented = true } - } + // Show the sync status sheet only for user-initiated syncs — the refresh button + // sets `isSyncSheetPresented`. Background / periodic syncs (including Model B's + // NE-driven periodic sync) update `syncState` silently without popping the sheet. + // Once presented, the sheet observes `syncState` for live progress and stays up + // through the terminal phase so the user sees the result, then dismisses manually. .sheet(isPresented: $isSyncSheetPresented) { - SyncStatusBottomSheet(state: appServices.propagationManager?.syncState ?? PropagationTransferState()) + // Container reads `syncState` in its own view body (not just inside this + // closure) so SwiftUI Observation reliably re-renders the sheet on every + // transfer-state change — live progress and the terminal result. + SyncStatusSheetContainer(manager: appServices.propagationManager) } #if os(iOS) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in @@ -332,7 +337,7 @@ struct ChatsView: View { // Sync with timeout so pull-to-refresh doesn't hang await withTaskGroup(of: Void.self) { group in group.addTask { - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) } group.addTask { try? await Task.sleep(for: .seconds(15)) @@ -377,8 +382,10 @@ struct ChatsView: View { // Refresh button — syncs from propagation node then reloads DB Button { + // User explicitly asked to sync → surface the status sheet. + isSyncSheetPresented = true Task { - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.refreshConversations() } } label: { @@ -411,6 +418,22 @@ struct ChatsView: View { } } +// MARK: - Sync Status Sheet Container + +/// Bridges the app's `@Observable` `PropagationNodeManager` to the pure, value-typed +/// `SyncStatusBottomSheet`. Reading `manager.syncState` inside this view's `body` +/// (rather than only inside the parent's `.sheet` content closure) guarantees SwiftUI +/// Observation re-renders the sheet whenever the transfer state changes, so live +/// progress and the terminal result always appear. +@available(iOS 17.0, macOS 14.0, *) +private struct SyncStatusSheetContainer: View { + let manager: PropagationNodeManager? + + var body: some View { + SyncStatusBottomSheet(state: manager?.syncState ?? PropagationTransferState()) + } +} + // MARK: - Button Style /// Custom button style for conversation rows with press animation. diff --git a/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift index 35af09f..46f6031 100644 --- a/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift +++ b/Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift @@ -20,40 +20,50 @@ struct SyncStatusBottomSheet: View { let state: PropagationTransferState var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Header - HStack(spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 24, weight: .semibold)) - .foregroundColor(Theme.accentColor) - Text("Propagation Node Sync") - .font(.title3.weight(.bold)) - .foregroundColor(Theme.textPrimary) - } + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(Theme.accentColor) + Text("Propagation Node Sync") + .font(.title3.weight(.bold)) + .foregroundColor(Theme.textPrimary) + } - Spacer().frame(height: 24) + Spacer().frame(height: 24) - // Status row - statusRow + // Status row + statusRow - // Progress bar (only while actively receiving with known progress) - if showProgressBar { - Spacer().frame(height: 16) - ProgressView(value: min(max(state.progress, 0), 1)) - .tint(Theme.accentColor) - Spacer().frame(height: 8) - Text("\(Int((min(max(state.progress, 0), 1)) * 100))%") - .font(.subheadline) - .foregroundColor(Theme.textSecondary) + // Progress bar (only while actively receiving with known progress) + if showProgressBar { + Spacer().frame(height: 16) + ProgressView(value: min(max(state.progress, 0), 1)) + .tint(Theme.accentColor) + Spacer().frame(height: 8) + Text("\(Int((min(max(state.progress, 0), 1)) * 100))%") + .font(.subheadline) + .foregroundColor(Theme.textSecondary) + } } + .padding(.horizontal, 24) + .padding(.top, 28) + .padding(.bottom, 36) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.horizontal, 24) - .padding(.top, 28) - .padding(.bottom, 36) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Theme.backgroundPrimary.ignoresSafeArea()) + // Scrolls only when content exceeds the detent (e.g. at large Dynamic Type); + // otherwise behaves like a static view, so the progress bar and percentage are + // never clipped at the bottom of the fixed-height sheet. + .scrollBounceBehavior(.basedOnSize) .presentationDetents([.height(220)]) .presentationDragIndicator(.visible) + // Color the entire sheet, not just the content. Using `.background(...)` on the + // content paints an opaque band sized to the VStack inside the system's default + // glass sheet chrome; `.presentationBackground` is the SwiftUI-correct way to set + // a sheet's backing surface (iOS 16.4+). + .presentationBackground(Theme.backgroundPrimary) } // MARK: - Status row diff --git a/Sources/ColumbaApp/Views/Messaging/MessagingView.swift b/Sources/ColumbaApp/Views/Messaging/MessagingView.swift index 0561a3e..4b499b5 100644 --- a/Sources/ColumbaApp/Views/Messaging/MessagingView.swift +++ b/Sources/ColumbaApp/Views/Messaging/MessagingView.swift @@ -533,7 +533,7 @@ struct MessagingView: View { Task { isSyncing = true defer { isSyncing = false } - await appServices.propagationManager?.syncNow() + await appServices.propagationManager?.syncNow(userInitiated: true) await viewModel?.loadMessages() } } label: {