Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/SyncStatusView.swift
T

169 lines
6.9 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]
/// Unfiltered — `uploadStatus` is matched in Swift rather than in a
/// #Predicate, per CLAUDE.md rules 3/48.
@Query private var allPendingPhotos: [PendingPhoto]
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
/// Photos that exhausted `SyncManager.maxPhotoUploadAttempts`. These are the
/// reason an inspection can be submitted with a blank photo field, and
/// nothing else in the app ever moves one off "failed" — so they belong in
/// the retry action too.
private var failedPhotos: [PendingPhoto] { allPendingPhotos.filter { $0.uploadStatus == "failed" } }
private var hasFailedItems: Bool {
!failedInspections.isEmpty || !failedIssues.isEmpty || !failedPhotos.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 + failedPhotos.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
}
// Photos too. A PendingPhoto only reaches "failed" after every upload
// attempt was used, and that is exactly the state that lets an
// inspection be submitted with its photo field blank — so without this
// the retry button could never actually recover a lost photo.
for photo in failedPhotos {
photo.uploadStatus = "pending"
photo.uploadRetryCount = 0
}
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)
}
}