05/02 Phase C
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// API/APIClient.swift
|
// API/APIClient.swift
|
||||||
// -------------------
|
// -------------------
|
||||||
// Central HTTP client for all JQC API calls.
|
// Central HTTP client for all JQC API calls.
|
||||||
// Phase B adds: uploadPhoto(), submitInspection(), submitIssue()
|
// Phase C adds: fetchInspectionHistory()
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
@@ -99,10 +99,24 @@ actor APIClient {
|
|||||||
return try await request(endpoint, method: "POST", body: body)
|
return try await request(endpoint, method: "POST", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inspection History (Phase C) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/// Fetch the inspector's synced inspection history from the server.
|
||||||
|
/// Returns up to `limit` records starting at `offset`.
|
||||||
|
func fetchInspectionHistory(
|
||||||
|
limit: Int = 50,
|
||||||
|
offset: Int = 0,
|
||||||
|
facilityId: Int? = nil
|
||||||
|
) async throws -> InspectionHistoryResponseData {
|
||||||
|
var endpoint = "/api/v1/inspections?limit=\(limit)&offset=\(offset)&status=completed"
|
||||||
|
if let fid = facilityId {
|
||||||
|
endpoint += "&facility_id=\(fid)"
|
||||||
|
}
|
||||||
|
return try await request(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo Upload (multipart/form-data) ────────────────────────────────
|
// ── Photo Upload (multipart/form-data) ────────────────────────────────
|
||||||
|
|
||||||
/// Upload a local photo file to the server.
|
|
||||||
/// Returns the server_path string on success.
|
|
||||||
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
|
||||||
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
|
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
@@ -115,12 +129,10 @@ actor APIClient {
|
|||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
var body = Data()
|
var body = Data()
|
||||||
|
|
||||||
// -- entity_type field
|
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!)
|
body.append("Content-Disposition: form-data; name=\"entity_type\"\r\n\r\n".data(using: .utf8)!)
|
||||||
body.append("\(entityType)\r\n".data(using: .utf8)!)
|
body.append("\(entityType)\r\n".data(using: .utf8)!)
|
||||||
|
|
||||||
// -- file field
|
|
||||||
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
let filename = URL(fileURLWithPath: localPath).lastPathComponent
|
||||||
let ext = (filename as NSString).pathExtension.lowercased()
|
let ext = (filename as NSString).pathExtension.lowercased()
|
||||||
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
|
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
|
||||||
@@ -171,8 +183,6 @@ actor APIClient {
|
|||||||
|
|
||||||
// ── Submit Inspection ─────────────────────────────────────────────────
|
// ── Submit Inspection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Submit a completed LocalInspection to the server.
|
|
||||||
/// Returns the server-assigned inspection ID.
|
|
||||||
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
|
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"template_id": inspection.templateServerId,
|
"template_id": inspection.templateServerId,
|
||||||
@@ -183,12 +193,8 @@ actor APIClient {
|
|||||||
"overall_score": inspection.overallScore as Any,
|
"overall_score": inspection.overallScore as Any,
|
||||||
]
|
]
|
||||||
|
|
||||||
if let areaId = inspection.areaServerId {
|
if let areaId = inspection.areaServerId { body["area_id"] = areaId }
|
||||||
body["area_id"] = areaId
|
if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
|
||||||
}
|
|
||||||
if !inspection.inspectorNotes.isEmpty {
|
|
||||||
body["notes"] = inspection.inspectorNotes
|
|
||||||
}
|
|
||||||
|
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
|
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
|
||||||
@@ -207,8 +213,6 @@ actor APIClient {
|
|||||||
|
|
||||||
// ── Submit Issue ──────────────────────────────────────────────────────
|
// ── Submit Issue ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Submit a LocalIssue to the server.
|
|
||||||
/// Returns the server-assigned issue ID.
|
|
||||||
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"area_id": issue.areaServerId,
|
"area_id": issue.areaServerId,
|
||||||
@@ -217,12 +221,9 @@ actor APIClient {
|
|||||||
"mobile_local_id": issue.localId,
|
"mobile_local_id": issue.localId,
|
||||||
]
|
]
|
||||||
|
|
||||||
// Link to server inspection if already synced
|
if let inspServerId = issue.inspection?.serverId {
|
||||||
// (inspection must be synced before its issues)
|
|
||||||
if let inspServerId = (issue.inspection?.serverId) {
|
|
||||||
body["inspection_id"] = inspServerId
|
body["inspection_id"] = inspServerId
|
||||||
}
|
}
|
||||||
|
|
||||||
if let serverPhotoPath = issue.photoServerPath {
|
if let serverPhotoPath = issue.photoServerPath {
|
||||||
body["photo_path"] = serverPhotoPath
|
body["photo_path"] = serverPhotoPath
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ struct APIUser: Decodable {
|
|||||||
let email: String
|
let email: String
|
||||||
let role: String
|
let role: String
|
||||||
let createdAt: String?
|
let createdAt: String?
|
||||||
|
|
||||||
// Computed display name: full name if present, otherwise username.
|
|
||||||
// The server's /auth/me endpoint returns username; display_name would
|
|
||||||
// require a separate field. For Phase A we use username as the display name
|
|
||||||
// and can extend later.
|
|
||||||
var displayName: String { username }
|
var displayName: String { username }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,18 +97,44 @@ struct APITemplate: Decodable, Identifiable {
|
|||||||
let name: String
|
let name: String
|
||||||
let description: String
|
let description: String
|
||||||
let frequency: String
|
let frequency: String
|
||||||
let formSchema: [[String: AnyDecodable]] // Dynamic JSON fields
|
let formSchema: [[String: AnyDecodable]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inspections (Phase C — history) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
struct InspectionHistoryResponseData: Decodable {
|
||||||
|
let inspections: [APIInspectionSummary]
|
||||||
|
let total: Int
|
||||||
|
let limit: Int
|
||||||
|
let offset: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct APIInspectionSummary: Decodable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let templateId: Int
|
||||||
|
let templateName: String
|
||||||
|
let facilityId: Int
|
||||||
|
let facilityName: String
|
||||||
|
let areaId: Int?
|
||||||
|
let areaName: String?
|
||||||
|
let status: String
|
||||||
|
let overallScore: Double?
|
||||||
|
let inspectionDate: String?
|
||||||
|
let completedAt: String?
|
||||||
|
let mobileLocalId: String?
|
||||||
|
|
||||||
|
var inspectionDateParsed: Date? {
|
||||||
|
guard let str = inspectionDate else { return nil }
|
||||||
|
return ISO8601DateFormatter().date(from: str)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AnyDecodable helper ───────────────────────────────────────────────────────
|
// ── AnyDecodable helper ───────────────────────────────────────────────────────
|
||||||
// Allows decoding JSON values of unknown type (String, Int, Bool, Array, Dict)
|
|
||||||
|
|
||||||
struct AnyDecodable: Decodable {
|
struct AnyDecodable: Decodable {
|
||||||
let value: Any
|
let value: Any
|
||||||
|
|
||||||
init(_ value: Any) {
|
init(_ value: Any) { self.value = value }
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.jqc.sync</string>
|
||||||
|
</array>
|
||||||
|
<key>Background Modes</key>
|
||||||
|
<array/>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Default</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
// JanitorialQC.swift
|
// JQCApp.swift
|
||||||
// ------------
|
// ------------
|
||||||
// App entry point. Phase B adds LocalInspection, LocalIssue,
|
// App entry point. Phase C adds BGTaskScheduler for background sync.
|
||||||
// PendingPhoto, and SyncQueueEntry to the SwiftData model container.
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import BackgroundTasks
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct JanitorialQC: App {
|
struct JanitorialQCApp: App {
|
||||||
|
|
||||||
@StateObject private var auth = AuthManager.shared
|
@StateObject private var auth = AuthManager.shared
|
||||||
@StateObject private var sync = SyncManager.shared
|
@StateObject private var sync = SyncManager.shared
|
||||||
|
|
||||||
|
init() {
|
||||||
|
registerBackgroundTasks()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
@@ -44,4 +48,49 @@ struct JanitorialQC: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Background Tasks ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Register the background sync task with the OS.
|
||||||
|
/// The identifier must match BGTaskSchedulerPermittedIdentifiers in Info.plist.
|
||||||
|
private func registerBackgroundTasks() {
|
||||||
|
BGTaskScheduler.shared.register(
|
||||||
|
forTaskWithIdentifier: "com.jqc.sync",
|
||||||
|
using: nil
|
||||||
|
) { task in
|
||||||
|
guard let processingTask = task as? BGProcessingTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleBackgroundSync(task: processingTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleBackgroundSync(task: BGProcessingTask) {
|
||||||
|
// Schedule the next background run immediately
|
||||||
|
scheduleBackgroundSync()
|
||||||
|
|
||||||
|
let syncTask = Task {
|
||||||
|
await SyncManager.shared.triggerSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the OS needs to cancel early, cancel our work
|
||||||
|
task.expirationHandler = {
|
||||||
|
syncTask.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await syncTask.value
|
||||||
|
task.setTaskCompleted(success: !syncTask.isCancelled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedule a background processing task.
|
||||||
|
/// Call this from sceneDidEnterBackground or after each foreground sync.
|
||||||
|
func scheduleBackgroundSync() {
|
||||||
|
let request = BGProcessingTaskRequest(identifier: "com.jqc.sync")
|
||||||
|
request.requiresNetworkConnectivity = true
|
||||||
|
request.requiresExternalPower = false
|
||||||
|
try? BGTaskScheduler.shared.submit(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Views/Dashboard/DashboardView.swift
|
// Views/Dashboard/DashboardView.swift
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
|
// Phase C: adds Inspection History tab, polished Settings with cache clear,
|
||||||
|
// and schedules background sync on scene enter background.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -11,9 +12,8 @@ struct DashboardView: View {
|
|||||||
@EnvironmentObject private var auth: AuthManager
|
@EnvironmentObject private var auth: AuthManager
|
||||||
@EnvironmentObject private var sync: SyncManager
|
@EnvironmentObject private var sync: SyncManager
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
|
|
||||||
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
|
||||||
@Query(
|
@Query(
|
||||||
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
||||||
sort: \LocalInspection.lastModifiedAt,
|
sort: \LocalInspection.lastModifiedAt,
|
||||||
@@ -25,8 +25,8 @@ struct DashboardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
// ── Sidebar ────────────────────────────────────────────────────
|
|
||||||
List {
|
List {
|
||||||
|
// My Inspections
|
||||||
Button { selectedTab = 0 } label: {
|
Button { selectedTab = 0 } label: {
|
||||||
HStack {
|
HStack {
|
||||||
Label("My Inspections", systemImage: "checklist")
|
Label("My Inspections", systemImage: "checklist")
|
||||||
@@ -35,8 +35,7 @@ struct DashboardView: View {
|
|||||||
if !myInspections.isEmpty {
|
if !myInspections.isEmpty {
|
||||||
Text("\(myInspections.count)")
|
Text("\(myInspections.count)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.blue.opacity(0.15))
|
.background(Color.blue.opacity(0.15))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
@@ -44,63 +43,70 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
|
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
// History
|
||||||
Button { selectedTab = 1 } label: {
|
Button { selectedTab = 1 } label: {
|
||||||
Label("Facilities", systemImage: "building.2")
|
Label("History", systemImage: "clock.arrow.circlepath")
|
||||||
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
|
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
// Facilities
|
||||||
Button { selectedTab = 2 } label: {
|
Button { selectedTab = 2 } label: {
|
||||||
Label("Templates", systemImage: "doc.text")
|
Label("Facilities", systemImage: "building.2")
|
||||||
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
|
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
|
||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
|
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
// Templates
|
||||||
Button { selectedTab = 3 } label: {
|
Button { selectedTab = 3 } label: {
|
||||||
|
Label("Templates", systemImage: "doc.text")
|
||||||
|
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
// Pending Sync
|
||||||
|
Button { selectedTab = 4 } label: {
|
||||||
HStack {
|
HStack {
|
||||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||||
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
|
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if sync.pendingCount > 0 {
|
if sync.pendingCount > 0 {
|
||||||
Text("\(sync.pendingCount)")
|
Text("\(sync.pendingCount)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Color.orange.opacity(0.2))
|
.background(Color.orange.opacity(0.2))
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
|
|
||||||
|
|
||||||
Button { selectedTab = 4 } label: {
|
|
||||||
Label("Settings", systemImage: "gear")
|
|
||||||
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
|
||||||
}
|
|
||||||
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
|
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
Button { selectedTab = 5 } label: {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
|
.foregroundStyle(selectedTab == 5 ? .blue : .primary)
|
||||||
|
}
|
||||||
|
.listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear)
|
||||||
}
|
}
|
||||||
.navigationTitle("JQC Inspector")
|
.navigationTitle("JQC Inspector")
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button { showNewInspection = true } label: {
|
||||||
showNewInspection = true
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||||
syncStatusFooter
|
|
||||||
}
|
|
||||||
|
|
||||||
} detail: {
|
} detail: {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case 0: MyInspectionsView()
|
case 0: MyInspectionsView()
|
||||||
case 1: FacilitiesListView()
|
case 1: InspectionHistoryView()
|
||||||
case 2: TemplatesListView()
|
case 2: FacilitiesListView()
|
||||||
case 3: SyncStatusView()
|
case 3: TemplatesListView()
|
||||||
|
case 4: SyncStatusView()
|
||||||
default: SettingsView()
|
default: SettingsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,10 +120,14 @@ struct DashboardView: View {
|
|||||||
sync.updatePendingCount(context: context)
|
sync.updatePendingCount(context: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Schedule background sync when app is backgrounded
|
||||||
|
.onChange(of: scenePhase) {
|
||||||
|
if scenePhase == .background {
|
||||||
|
scheduleBackgroundSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync Status Footer ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private var syncStatusFooter: some View {
|
private var syncStatusFooter: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -130,8 +140,7 @@ struct DashboardView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if sync.isSyncing {
|
if sync.isSyncing {
|
||||||
ProgressView()
|
ProgressView().scaleEffect(0.7)
|
||||||
.scaleEffect(0.7)
|
|
||||||
} else if let lastSync = sync.lastSyncAt {
|
} else if let lastSync = sync.lastSyncAt {
|
||||||
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -203,23 +212,18 @@ struct InspectionRowView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(templateName)
|
Text(templateName).font(.headline)
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||||
}
|
}
|
||||||
Text(facilityName)
|
Text(facilityName).font(.callout).foregroundStyle(.secondary)
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
|
||||||
.font(.caption2)
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
if let score = inspection.overallScore {
|
if let score = inspection.overallScore {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.1f%%", score))
|
Text(String(format: "%.1f%%", score))
|
||||||
.font(.caption)
|
.font(.caption).fontWeight(.medium)
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,10 +238,10 @@ struct StatusBadge: View {
|
|||||||
|
|
||||||
var label: String {
|
var label: String {
|
||||||
switch status {
|
switch status {
|
||||||
case "draft": return "Draft"
|
case "draft": return "Draft"
|
||||||
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
|
||||||
case "sync_failed": return "Sync Failed"
|
case "failed": return "Sync Failed"
|
||||||
default: return status.capitalized
|
default: return status.capitalized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +249,7 @@ struct StatusBadge: View {
|
|||||||
switch status {
|
switch status {
|
||||||
case "draft": return .blue
|
case "draft": return .blue
|
||||||
case "completed": return syncStatus == "pending" ? .orange : .green
|
case "completed": return syncStatus == "pending" ? .orange : .green
|
||||||
case "sync_failed": return .red
|
case "failed": return .red
|
||||||
default: return .secondary
|
default: return .secondary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,15 +257,14 @@ struct StatusBadge: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||||
.padding(.vertical, 3)
|
|
||||||
.background(color.opacity(0.15))
|
.background(color.opacity(0.15))
|
||||||
.foregroundStyle(color)
|
.foregroundStyle(color)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Completed Inspection (read-only view)
|
// MARK: - Completed Inspection (read-only)
|
||||||
|
|
||||||
struct CompletedInspectionView: View {
|
struct CompletedInspectionView: View {
|
||||||
let inspection: LocalInspection
|
let inspection: LocalInspection
|
||||||
@@ -277,14 +280,11 @@ struct CompletedInspectionView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
// Summary card
|
|
||||||
GroupBox {
|
GroupBox {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let score = inspection.overallScore {
|
if let score = inspection.overallScore {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Overall Score")
|
Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.1f%%", score))
|
Text(String(format: "%.1f%%", score))
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
@@ -293,51 +293,39 @@ struct CompletedInspectionView: View {
|
|||||||
}
|
}
|
||||||
if let completedAt = inspection.completedAt {
|
if let completedAt = inspection.completedAt {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Completed")
|
Text("Completed").font(.subheadline).foregroundStyle(.secondary)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
Text("Sync Status")
|
Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
StatusBadge(status: inspection.status,
|
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
|
||||||
syncStatus: inspection.syncStatus)
|
|
||||||
}
|
}
|
||||||
if let error = inspection.syncErrorMessage {
|
if let error = inspection.syncErrorMessage {
|
||||||
Text("Error: \(error)")
|
Text("Error: \(error)").font(.caption).foregroundStyle(.red)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Issues
|
|
||||||
if !inspection.localIssues.isEmpty {
|
if !inspection.localIssues.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Flagged Issues (\(inspection.localIssues.count))")
|
Text("Flagged Issues (\(inspection.localIssues.count))")
|
||||||
.font(.headline)
|
.font(.headline).padding(.horizontal)
|
||||||
.padding(.horizontal)
|
|
||||||
ForEach(inspection.localIssues) { issue in
|
ForEach(inspection.localIssues) { issue in
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(issue.severity == "critical" ? Color.red :
|
.fill(issue.severity == "critical" ? Color.red :
|
||||||
issue.severity == "high" ? Color.orange :
|
issue.severity == "high" ? Color.orange :
|
||||||
issue.severity == "medium" ? Color.yellow : Color.blue)
|
issue.severity == "medium" ? Color.yellow : Color.blue)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8).padding(.top, 4)
|
||||||
.padding(.top, 4)
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(issue.severity.capitalized)
|
Text(issue.severity.capitalized)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold()).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
Text(issue.issueDescription).font(.callout)
|
||||||
Text(issue.issueDescription)
|
|
||||||
.font(.callout)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -355,7 +343,6 @@ struct CompletedInspectionView: View {
|
|||||||
// MARK: - Sync Status View
|
// MARK: - Sync Status View
|
||||||
|
|
||||||
struct SyncStatusView: View {
|
struct SyncStatusView: View {
|
||||||
|
|
||||||
@EnvironmentObject private var sync: SyncManager
|
@EnvironmentObject private var sync: SyncManager
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
@@ -373,8 +360,7 @@ struct SyncStatusView: View {
|
|||||||
List {
|
List {
|
||||||
Section("Status") {
|
Section("Status") {
|
||||||
HStack {
|
HStack {
|
||||||
Circle()
|
Circle().fill(sync.isOnline ? Color.green : Color.orange)
|
||||||
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
Text(sync.isOnline ? "Online" : "Offline")
|
Text(sync.isOnline ? "Online" : "Offline")
|
||||||
}
|
}
|
||||||
@@ -385,8 +371,7 @@ struct SyncStatusView: View {
|
|||||||
if sync.isSyncing {
|
if sync.isSyncing {
|
||||||
HStack {
|
HStack {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("Syncing…")
|
Text("Syncing…").foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let error = sync.syncError {
|
if let error = sync.syncError {
|
||||||
@@ -402,14 +387,10 @@ struct SyncStatusView: View {
|
|||||||
|
|
||||||
if !pendingInspections.isEmpty {
|
if !pendingInspections.isEmpty {
|
||||||
Section("Pending Inspections (\(pendingInspections.count))") {
|
Section("Pending Inspections (\(pendingInspections.count))") {
|
||||||
ForEach(pendingInspections) { inspection in
|
ForEach(pendingInspections) { insp in
|
||||||
SyncRowView(
|
SyncRowView(title: "Inspection", status: insp.syncStatus,
|
||||||
title: "Inspection",
|
retryCount: insp.syncRetryCount,
|
||||||
status: inspection.syncStatus,
|
error: insp.syncErrorMessage, date: insp.createdAt)
|
||||||
retryCount: inspection.syncRetryCount,
|
|
||||||
error: inspection.syncErrorMessage,
|
|
||||||
date: inspection.createdAt
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,13 +398,9 @@ struct SyncStatusView: View {
|
|||||||
if !pendingIssues.isEmpty {
|
if !pendingIssues.isEmpty {
|
||||||
Section("Pending Issues (\(pendingIssues.count))") {
|
Section("Pending Issues (\(pendingIssues.count))") {
|
||||||
ForEach(pendingIssues) { issue in
|
ForEach(pendingIssues) { issue in
|
||||||
SyncRowView(
|
SyncRowView(title: "\(issue.severity.capitalized) Issue",
|
||||||
title: "\(issue.severity.capitalized) Issue",
|
status: issue.syncStatus, retryCount: issue.syncRetryCount,
|
||||||
status: issue.syncStatus,
|
error: issue.syncErrorMessage, date: issue.createdAt)
|
||||||
retryCount: issue.syncRetryCount,
|
|
||||||
error: issue.syncErrorMessage,
|
|
||||||
date: issue.createdAt
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,30 +428,24 @@ struct SyncRowView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text(title).font(.callout)
|
Text(title).font(.callout)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(status.capitalized)
|
Text(status.capitalized).font(.caption2)
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(status == "failed" ? .red : .orange)
|
.foregroundStyle(status == "failed" ? .red : .orange)
|
||||||
}
|
}
|
||||||
Text(date.formatted(date: .abbreviated, time: .shortened))
|
Text(date.formatted(date: .abbreviated, time: .shortened))
|
||||||
.font(.caption2)
|
.font(.caption2).foregroundStyle(.tertiary)
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
if let err = error {
|
if let err = error {
|
||||||
Text(err)
|
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
}
|
||||||
if retryCount > 0 {
|
if retryCount > 0 {
|
||||||
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
||||||
.font(.caption2)
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
|
// MARK: - Facilities
|
||||||
|
|
||||||
struct FacilitiesListView: View {
|
struct FacilitiesListView: View {
|
||||||
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
||||||
@@ -482,11 +453,8 @@ struct FacilitiesListView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if facilities.isEmpty {
|
if facilities.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView("No Facilities", systemImage: "building.2.slash",
|
||||||
"No Facilities",
|
description: Text("Connect to the internet to sync your assigned facilities."))
|
||||||
systemImage: "building.2.slash",
|
|
||||||
description: Text("Connect to the internet to sync your assigned facilities.")
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
List(facilities) { facility in
|
List(facilities) { facility in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -508,17 +476,16 @@ struct FacilitiesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Templates
|
||||||
|
|
||||||
struct TemplatesListView: View {
|
struct TemplatesListView: View {
|
||||||
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if templates.isEmpty {
|
if templates.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView("No Templates", systemImage: "doc.text.magnifyingglass",
|
||||||
"No Templates",
|
description: Text("Connect to the internet to sync inspection templates."))
|
||||||
systemImage: "doc.text.magnifyingglass",
|
|
||||||
description: Text("Connect to the internet to sync inspection templates.")
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
List(templates) { template in
|
List(templates) { template in
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
@@ -545,9 +512,15 @@ struct TemplatesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject private var auth: AuthManager
|
@EnvironmentObject private var auth: AuthManager
|
||||||
@EnvironmentObject private var sync: SyncManager
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
@State private var showClearCacheAlert = false
|
||||||
|
@State private var cacheCleared = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -555,15 +528,43 @@ struct SettingsView: View {
|
|||||||
LabeledContent("Username", value: auth.currentUsername)
|
LabeledContent("Username", value: auth.currentUsername)
|
||||||
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Sync") {
|
Section("Sync") {
|
||||||
Button { Task { await sync.triggerSync() } } label: {
|
Button {
|
||||||
|
Task { await sync.triggerSync() }
|
||||||
|
} label: {
|
||||||
Label("Sync Now", systemImage: "arrow.clockwise")
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.disabled(!sync.isOnline || sync.isSyncing)
|
.disabled(!sync.isOnline || sync.isSyncing)
|
||||||
|
|
||||||
if let error = sync.syncError {
|
if let error = sync.syncError {
|
||||||
Text(error).font(.caption).foregroundStyle(.red)
|
Text(error).font(.caption).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let lastSync = sync.lastSyncAt {
|
||||||
|
LabeledContent("Last Sync",
|
||||||
|
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Cache") {
|
||||||
|
Button {
|
||||||
|
showClearCacheAlert = true
|
||||||
|
} label: {
|
||||||
|
Label("Clear Reference Cache", systemImage: "trash")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
Text("Clears locally cached facilities, areas, and templates. Your pending inspections are not affected. Data will re-sync on the next connection.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if cacheCleared {
|
||||||
|
Label("Cache cleared.", systemImage: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
Task { await auth.logout() }
|
Task { await auth.logout() }
|
||||||
@@ -571,11 +572,37 @@ struct SettingsView: View {
|
|||||||
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("App Info") {
|
Section("App Info") {
|
||||||
LabeledContent("Version", value: "Phase B")
|
LabeledContent("Version", value: "Phase C")
|
||||||
LabeledContent("Server", value: Constants.baseURL)
|
LabeledContent("Server", value: Constants.baseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
|
.alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) {
|
||||||
|
Button("Clear", role: .destructive) { clearCache() }
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearCache() {
|
||||||
|
// Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto
|
||||||
|
let facilities = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||||
|
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||||||
|
let areas = (try? context.fetch(FetchDescriptor<LocalArea>())) ?? []
|
||||||
|
|
||||||
|
facilities.forEach { context.delete($0) }
|
||||||
|
templates.forEach { context.delete($0) }
|
||||||
|
areas.forEach { context.delete($0) }
|
||||||
|
|
||||||
|
try? context.save()
|
||||||
|
cacheCleared = true
|
||||||
|
|
||||||
|
// Re-pull immediately if online
|
||||||
|
if sync.isOnline {
|
||||||
|
Task { await sync.pullReferenceData() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Views/Inspection/InspectionHistoryView.swift
|
||||||
|
// --------------------------------------------
|
||||||
|
// Shows the inspector's synced inspection history fetched from the server.
|
||||||
|
// Only available when online. Displays score, facility, template, and date.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct InspectionHistoryView: View {
|
||||||
|
|
||||||
|
@EnvironmentObject private var sync: SyncManager
|
||||||
|
@State private var inspections: [APIInspectionSummary] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var total = 0
|
||||||
|
@State private var offset = 0
|
||||||
|
private let limit = 30
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if !sync.isOnline && inspections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Offline",
|
||||||
|
systemImage: "wifi.slash",
|
||||||
|
description: Text("Inspection history requires an internet connection.")
|
||||||
|
)
|
||||||
|
} else if isLoading && inspections.isEmpty {
|
||||||
|
ProgressView("Loading history…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let error = errorMessage, inspections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Could Not Load",
|
||||||
|
systemImage: "exclamationmark.triangle",
|
||||||
|
description: Text(error)
|
||||||
|
)
|
||||||
|
} else if inspections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No History",
|
||||||
|
systemImage: "clock.arrow.circlepath",
|
||||||
|
description: Text("Completed inspections will appear here after syncing.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(inspections) { inspection in
|
||||||
|
HistoryRowView(inspection: inspection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more
|
||||||
|
if inspections.count < total {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("Load More") {
|
||||||
|
Task { await loadMore() }
|
||||||
|
}
|
||||||
|
.disabled(isLoading)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await load(reset: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Inspection History")
|
||||||
|
.task {
|
||||||
|
if sync.isOnline {
|
||||||
|
await load(reset: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: sync.isOnline) {
|
||||||
|
if sync.isOnline && inspections.isEmpty {
|
||||||
|
Task { await load(reset: true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(reset: Bool) async {
|
||||||
|
if reset { offset = 0 }
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await APIClient.shared.fetchInspectionHistory(
|
||||||
|
limit: limit, offset: reset ? 0 : offset
|
||||||
|
)
|
||||||
|
if reset {
|
||||||
|
inspections = result.inspections
|
||||||
|
} else {
|
||||||
|
inspections.append(contentsOf: result.inspections)
|
||||||
|
}
|
||||||
|
total = result.total
|
||||||
|
offset = inspections.count
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMore() async {
|
||||||
|
await load(reset: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - History Row
|
||||||
|
|
||||||
|
struct HistoryRowView: View {
|
||||||
|
let inspection: APIInspectionSummary
|
||||||
|
|
||||||
|
private var scoreColor: Color {
|
||||||
|
guard let score = inspection.overallScore else { return .secondary }
|
||||||
|
return score >= 80 ? .green : score >= 60 ? .orange : .red
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateText: String {
|
||||||
|
guard let date = inspection.inspectionDateParsed else {
|
||||||
|
return inspection.inspectionDate ?? ""
|
||||||
|
}
|
||||||
|
return date.formatted(date: .abbreviated, time: .shortened)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(inspection.templateName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(inspection.facilityName)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
if let area = inspection.areaName {
|
||||||
|
Text(area)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if let score = inspection.overallScore {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(String(format: "%.1f%%", score))
|
||||||
|
.font(.title3.bold())
|
||||||
|
.foregroundStyle(scoreColor)
|
||||||
|
Text("Score")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Text(dateText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
Spacer()
|
||||||
|
// Sync origin badge
|
||||||
|
if inspection.mobileLocalId != nil {
|
||||||
|
Label("Mobile", systemImage: "ipad")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user