Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Sources/ColumbaApp/Services/PropagationNodeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/ColumbaApp/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 34 additions & 11 deletions Sources/ColumbaApp/Views/Chats/ChatsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 37 additions & 27 deletions Sources/ColumbaApp/Views/Components/SyncStatusBottomSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Sources/ColumbaApp/Views/Messaging/MessagingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down