// Views/Dashboard/SyncStatusView.swift import SwiftUI import SwiftData import MessageUI // MARK: - Sync Status View struct SyncStatusView: View { @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @Query( filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, sort: \LocalInspection.createdAt ) private var pendingInspections: [LocalInspection] @Query( filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, sort: \LocalIssue.createdAt ) private var pendingIssues: [LocalIssue] private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } } private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } } private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty } var body: some View { List { Section("Status") { HStack { Circle().fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) Text(sync.isOnline ? "Online" : "Offline") } if let lastSync = sync.lastSyncAt { LabeledContent("Last Sync", value: lastSync.formatted(date: .abbreviated, time: .shortened)) } if sync.isSyncing { HStack { ProgressView() Text("Syncing…").foregroundStyle(.secondary) } } if let error = sync.syncError { Text(error).foregroundStyle(.red).font(.callout) } Button { Task { await sync.triggerSync() } } label: { Label("Sync Now", systemImage: "arrow.clockwise") } .disabled(!sync.isOnline || sync.isSyncing) // Retry Failed Items — resets syncStatus back to "pending" so // the next triggerSync() will re-attempt them. Once an item // reaches syncStatus = "failed" (after 5 consecutive errors) // triggerSync() stops picking it up — this is the only way // to re-queue it without manual server intervention. if hasFailedItems { Button { retryAllFailed() } label: { Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))", systemImage: "exclamationmark.arrow.circlepath") .foregroundStyle(.orange) } .disabled(!sync.isOnline || sync.isSyncing) } } if !pendingInspections.isEmpty { Section("Pending Inspections (\(pendingInspections.count))") { ForEach(pendingInspections) { insp in SyncRowView(title: "Inspection", status: insp.syncStatus, retryCount: insp.syncRetryCount, error: insp.syncErrorMessage, date: insp.createdAt) } } } if !pendingIssues.isEmpty { Section("Pending Issues (\(pendingIssues.count))") { ForEach(pendingIssues) { issue in SyncRowView(title: "\(issue.severity.capitalized) Issue", status: issue.syncStatus, retryCount: issue.syncRetryCount, error: issue.syncErrorMessage, date: issue.createdAt) } } } if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing { Section { Label("All items synced.", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } } .navigationTitle("Pending Sync") } /// Reset all failed items back to pending so the next sync pass picks them up. /// Also clears syncRetryCount and syncErrorMessage so the retry counter /// starts fresh — prevents them immediately hitting the 5-retry cap again /// without any actual new attempt. private func retryAllFailed() { for insp in failedInspections { insp.syncStatus = "pending" insp.syncRetryCount = 0 insp.syncErrorMessage = nil } for issue in failedIssues { issue.syncStatus = "pending" issue.syncRetryCount = 0 issue.syncErrorMessage = nil } try? context.save() Task { await sync.triggerSync() } } } struct SyncRowView: View { let title: String let status: String let retryCount: Int let error: String? let date: Date var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(title).font(.callout) Spacer() Text(status.capitalized).font(.caption2) .foregroundStyle(status == "failed" ? .red : .orange) } Text(date.formatted(date: .abbreviated, time: .shortened)) .font(.caption2).foregroundStyle(.tertiary) if let err = error { Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2) } if retryCount > 0 { Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.secondary) } } .padding(.vertical, 2) } }