150 lines
5.8 KiB
Swift
150 lines
5.8 KiB
Swift
// 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<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
|
sort: \LocalInspection.createdAt
|
|
) private var pendingInspections: [LocalInspection]
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalIssue> { $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)
|
|
}
|
|
}
|
|
|