diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift
index 28abbb8..7892d5f 100644
--- a/JanitorialQC/API/APIClient.swift
+++ b/JanitorialQC/API/APIClient.swift
@@ -1,7 +1,7 @@
// API/APIClient.swift
// -------------------
// Central HTTP client for all JQC API calls.
-// Phase B adds: uploadPhoto(), submitInspection(), submitIssue()
+// Phase C adds: fetchInspectionHistory()
import Foundation
import Combine
@@ -99,10 +99,24 @@ actor APIClient {
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) ────────────────────────────────
- /// Upload a local photo file to the server.
- /// Returns the server_path string on success.
func uploadPhoto(localPath: String, entityType: String) async throws -> String {
guard let url = URL(string: baseURL + "/api/v1/photos/upload") else {
throw APIError.invalidURL
@@ -115,12 +129,10 @@ actor APIClient {
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
- // -- entity_type field
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("\(entityType)\r\n".data(using: .utf8)!)
- // -- file field
let filename = URL(fileURLWithPath: localPath).lastPathComponent
let ext = (filename as NSString).pathExtension.lowercased()
let mimeType = ext == "png" ? "image/png" : "image/jpeg"
@@ -171,8 +183,6 @@ actor APIClient {
// ── Submit Inspection ─────────────────────────────────────────────────
- /// Submit a completed LocalInspection to the server.
- /// Returns the server-assigned inspection ID.
func submitInspection(_ inspection: LocalInspection) async throws -> Int {
var body: [String: Any] = [
"template_id": inspection.templateServerId,
@@ -183,12 +193,8 @@ actor APIClient {
"overall_score": inspection.overallScore as Any,
]
- if let areaId = inspection.areaServerId {
- body["area_id"] = areaId
- }
- if !inspection.inspectorNotes.isEmpty {
- body["notes"] = inspection.inspectorNotes
- }
+ if let areaId = inspection.areaServerId { body["area_id"] = areaId }
+ if !inspection.inspectorNotes.isEmpty { body["notes"] = inspection.inspectorNotes }
let formatter = ISO8601DateFormatter()
body["inspection_date"] = formatter.string(from: inspection.inspectionDate)
@@ -207,8 +213,6 @@ actor APIClient {
// ── Submit Issue ──────────────────────────────────────────────────────
- /// Submit a LocalIssue to the server.
- /// Returns the server-assigned issue ID.
func submitIssue(_ issue: LocalIssue) async throws -> Int {
var body: [String: Any] = [
"area_id": issue.areaServerId,
@@ -217,12 +221,9 @@ actor APIClient {
"mobile_local_id": issue.localId,
]
- // Link to server inspection if already synced
- // (inspection must be synced before its issues)
- if let inspServerId = (issue.inspection?.serverId) {
+ if let inspServerId = issue.inspection?.serverId {
body["inspection_id"] = inspServerId
}
-
if let serverPhotoPath = issue.photoServerPath {
body["photo_path"] = serverPhotoPath
}
diff --git a/JanitorialQC/API/APIModels.swift b/JanitorialQC/API/APIModels.swift
index 890e678..01dbf29 100644
--- a/JanitorialQC/API/APIModels.swift
+++ b/JanitorialQC/API/APIModels.swift
@@ -40,11 +40,6 @@ struct APIUser: Decodable {
let email: String
let role: 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 }
}
@@ -102,18 +97,44 @@ struct APITemplate: Decodable, Identifiable {
let name: String
let description: 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 ───────────────────────────────────────────────────────
-// Allows decoding JSON values of unknown type (String, Int, Bool, Array, Dict)
struct AnyDecodable: Decodable {
let value: Any
- init(_ value: Any) {
- self.value = value
- }
+ init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
diff --git a/JanitorialQC/Info.plist b/JanitorialQC/Info.plist
new file mode 100644
index 0000000..e2e8562
--- /dev/null
+++ b/JanitorialQC/Info.plist
@@ -0,0 +1,23 @@
+
+
+
+
+ BGTaskSchedulerPermittedIdentifiers
+
+ com.jqc.sync
+
+ Background Modes
+
+ CFBundleDocumentTypes
+
+
+ LSHandlerRank
+ Default
+
+
+ UIBackgroundModes
+
+ processing
+
+
+
diff --git a/JanitorialQC/JanitorialQCApp.swift b/JanitorialQC/JanitorialQCApp.swift
index 0d92da8..43bb301 100644
--- a/JanitorialQC/JanitorialQCApp.swift
+++ b/JanitorialQC/JanitorialQCApp.swift
@@ -1,17 +1,21 @@
-// JanitorialQC.swift
+// JQCApp.swift
// ------------
-// App entry point. Phase B adds LocalInspection, LocalIssue,
-// PendingPhoto, and SyncQueueEntry to the SwiftData model container.
+// App entry point. Phase C adds BGTaskScheduler for background sync.
import SwiftUI
import SwiftData
+import BackgroundTasks
@main
-struct JanitorialQC: App {
+struct JanitorialQCApp: App {
@StateObject private var auth = AuthManager.shared
@StateObject private var sync = SyncManager.shared
+ init() {
+ registerBackgroundTasks()
+ }
+
var body: some Scene {
WindowGroup {
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)
}
diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift
index 70b04e5..63d2ce8 100644
--- a/JanitorialQC/Views/Dashboard/DashboardView.swift
+++ b/JanitorialQC/Views/Dashboard/DashboardView.swift
@@ -1,6 +1,7 @@
// 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 SwiftData
@@ -11,9 +12,8 @@ struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
@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(
filter: #Predicate { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
@@ -25,8 +25,8 @@ struct DashboardView: View {
var body: some View {
NavigationSplitView {
- // ── Sidebar ────────────────────────────────────────────────────
List {
+ // My Inspections
Button { selectedTab = 0 } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
@@ -35,8 +35,7 @@ struct DashboardView: View {
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
+ .padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
@@ -44,63 +43,70 @@ struct DashboardView: View {
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
+ // History
Button { selectedTab = 1 } label: {
- Label("Facilities", systemImage: "building.2")
+ Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
}
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
+ // Facilities
Button { selectedTab = 2 } label: {
- Label("Templates", systemImage: "doc.text")
+ Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
}
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
+ // Templates
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 {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
- .foregroundStyle(selectedTab == 3 ? .blue : .primary)
+ .foregroundStyle(selectedTab == 4 ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
- .padding(.horizontal, 6)
- .padding(.vertical, 2)
+ .padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.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)
+
+ // 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")
.listStyle(.sidebar)
.toolbar {
ToolbarItem(placement: .primaryAction) {
- Button {
- showNewInspection = true
- } label: {
+ Button { showNewInspection = true } label: {
Image(systemName: "plus")
}
}
}
- .safeAreaInset(edge: .bottom) {
- syncStatusFooter
- }
+ .safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
switch selectedTab {
case 0: MyInspectionsView()
- case 1: FacilitiesListView()
- case 2: TemplatesListView()
- case 3: SyncStatusView()
+ case 1: InspectionHistoryView()
+ case 2: FacilitiesListView()
+ case 3: TemplatesListView()
+ case 4: SyncStatusView()
default: SettingsView()
}
}
@@ -114,10 +120,14 @@ struct DashboardView: View {
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 {
VStack(spacing: 0) {
Divider()
@@ -130,8 +140,7 @@ struct DashboardView: View {
.foregroundStyle(.secondary)
Spacer()
if sync.isSyncing {
- ProgressView()
- .scaleEffect(0.7)
+ ProgressView().scaleEffect(0.7)
} else if let lastSync = sync.lastSyncAt {
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption2)
@@ -203,23 +212,18 @@ struct InspectionRowView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
- Text(templateName)
- .font(.headline)
+ Text(templateName).font(.headline)
Spacer()
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
- Text(facilityName)
- .font(.callout)
- .foregroundStyle(.secondary)
+ Text(facilityName).font(.callout).foregroundStyle(.secondary)
HStack {
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
- .font(.caption2)
- .foregroundStyle(.tertiary)
+ .font(.caption2).foregroundStyle(.tertiary)
if let score = inspection.overallScore {
Spacer()
Text(String(format: "%.1f%%", score))
- .font(.caption)
- .fontWeight(.medium)
+ .font(.caption).fontWeight(.medium)
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
@@ -234,10 +238,10 @@ struct StatusBadge: View {
var label: String {
switch status {
- case "draft": return "Draft"
- case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
- case "sync_failed": return "Sync Failed"
- default: return status.capitalized
+ case "draft": return "Draft"
+ case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
+ case "failed": return "Sync Failed"
+ default: return status.capitalized
}
}
@@ -245,7 +249,7 @@ struct StatusBadge: View {
switch status {
case "draft": return .blue
case "completed": return syncStatus == "pending" ? .orange : .green
- case "sync_failed": return .red
+ case "failed": return .red
default: return .secondary
}
}
@@ -253,15 +257,14 @@ struct StatusBadge: View {
var body: some View {
Text(label)
.font(.caption2)
- .padding(.horizontal, 8)
- .padding(.vertical, 3)
+ .padding(.horizontal, 8).padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
-// MARK: - Completed Inspection (read-only view)
+// MARK: - Completed Inspection (read-only)
struct CompletedInspectionView: View {
let inspection: LocalInspection
@@ -277,14 +280,11 @@ struct CompletedInspectionView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
- // Summary card
GroupBox {
VStack(alignment: .leading, spacing: 8) {
if let score = inspection.overallScore {
HStack {
- Text("Overall Score")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ Text("Overall Score").font(.subheadline).foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.1f%%", score))
.font(.title2.bold())
@@ -293,51 +293,39 @@ struct CompletedInspectionView: View {
}
if let completedAt = inspection.completedAt {
HStack {
- Text("Completed")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ Text("Completed").font(.subheadline).foregroundStyle(.secondary)
Spacer()
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
.font(.callout)
}
}
HStack {
- Text("Sync Status")
- .font(.subheadline)
- .foregroundStyle(.secondary)
+ Text("Sync Status").font(.subheadline).foregroundStyle(.secondary)
Spacer()
- StatusBadge(status: inspection.status,
- syncStatus: inspection.syncStatus)
+ StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
if let error = inspection.syncErrorMessage {
- Text("Error: \(error)")
- .font(.caption)
- .foregroundStyle(.red)
+ Text("Error: \(error)").font(.caption).foregroundStyle(.red)
}
}
}
.padding(.horizontal)
- // Issues
if !inspection.localIssues.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Flagged Issues (\(inspection.localIssues.count))")
- .font(.headline)
- .padding(.horizontal)
+ .font(.headline).padding(.horizontal)
ForEach(inspection.localIssues) { issue in
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(issue.severity == "critical" ? Color.red :
issue.severity == "high" ? Color.orange :
issue.severity == "medium" ? Color.yellow : Color.blue)
- .frame(width: 8, height: 8)
- .padding(.top, 4)
+ .frame(width: 8, height: 8).padding(.top, 4)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
- .font(.caption.bold())
- .foregroundStyle(.secondary)
- Text(issue.issueDescription)
- .font(.callout)
+ .font(.caption.bold()).foregroundStyle(.secondary)
+ Text(issue.issueDescription).font(.callout)
}
}
.padding(.horizontal)
@@ -355,7 +343,6 @@ struct CompletedInspectionView: View {
// MARK: - Sync Status View
struct SyncStatusView: View {
-
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@@ -373,8 +360,7 @@ struct SyncStatusView: View {
List {
Section("Status") {
HStack {
- Circle()
- .fill(sync.isOnline ? Color.green : Color.orange)
+ Circle().fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
}
@@ -385,8 +371,7 @@ struct SyncStatusView: View {
if sync.isSyncing {
HStack {
ProgressView()
- Text("Syncing…")
- .foregroundStyle(.secondary)
+ Text("Syncing…").foregroundStyle(.secondary)
}
}
if let error = sync.syncError {
@@ -402,14 +387,10 @@ struct SyncStatusView: View {
if !pendingInspections.isEmpty {
Section("Pending Inspections (\(pendingInspections.count))") {
- ForEach(pendingInspections) { inspection in
- SyncRowView(
- title: "Inspection",
- status: inspection.syncStatus,
- retryCount: inspection.syncRetryCount,
- error: inspection.syncErrorMessage,
- date: inspection.createdAt
- )
+ ForEach(pendingInspections) { insp in
+ SyncRowView(title: "Inspection", status: insp.syncStatus,
+ retryCount: insp.syncRetryCount,
+ error: insp.syncErrorMessage, date: insp.createdAt)
}
}
}
@@ -417,13 +398,9 @@ struct SyncStatusView: View {
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
- )
+ SyncRowView(title: "\(issue.severity.capitalized) Issue",
+ status: issue.syncStatus, retryCount: issue.syncRetryCount,
+ error: issue.syncErrorMessage, date: issue.createdAt)
}
}
}
@@ -451,30 +428,24 @@ struct SyncRowView: View {
HStack {
Text(title).font(.callout)
Spacer()
- Text(status.capitalized)
- .font(.caption2)
+ Text(status.capitalized).font(.caption2)
.foregroundStyle(status == "failed" ? .red : .orange)
}
Text(date.formatted(date: .abbreviated, time: .shortened))
- .font(.caption2)
- .foregroundStyle(.tertiary)
+ .font(.caption2).foregroundStyle(.tertiary)
if let err = error {
- Text(err)
- .font(.caption2)
- .foregroundStyle(.red)
- .lineLimit(2)
+ Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
}
if retryCount > 0 {
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
- .font(.caption2)
- .foregroundStyle(.secondary)
+ .font(.caption2).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
-// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
+// MARK: - Facilities
struct FacilitiesListView: View {
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
@@ -482,11 +453,8 @@ struct FacilitiesListView: View {
var body: some View {
Group {
if facilities.isEmpty {
- ContentUnavailableView(
- "No Facilities",
- systemImage: "building.2.slash",
- description: Text("Connect to the internet to sync your assigned facilities.")
- )
+ ContentUnavailableView("No Facilities", systemImage: "building.2.slash",
+ description: Text("Connect to the internet to sync your assigned facilities."))
} else {
List(facilities) { facility in
VStack(alignment: .leading, spacing: 4) {
@@ -508,17 +476,16 @@ struct FacilitiesListView: View {
}
}
+// MARK: - Templates
+
struct TemplatesListView: View {
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
var body: some View {
Group {
if templates.isEmpty {
- ContentUnavailableView(
- "No Templates",
- systemImage: "doc.text.magnifyingglass",
- description: Text("Connect to the internet to sync inspection templates.")
- )
+ ContentUnavailableView("No Templates", systemImage: "doc.text.magnifyingglass",
+ description: Text("Connect to the internet to sync inspection templates."))
} else {
List(templates) { template in
VStack(alignment: .leading, spacing: 4) {
@@ -545,9 +512,15 @@ struct TemplatesListView: View {
}
}
+// MARK: - Settings
+
struct SettingsView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
+ @Environment(\.modelContext) private var context
+
+ @State private var showClearCacheAlert = false
+ @State private var cacheCleared = false
var body: some View {
List {
@@ -555,15 +528,43 @@ struct SettingsView: View {
LabeledContent("Username", value: auth.currentUsername)
LabeledContent("Role", value: auth.currentUserRole.capitalized)
}
+
Section("Sync") {
- Button { Task { await sync.triggerSync() } } label: {
+ Button {
+ Task { await sync.triggerSync() }
+ } label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
+
if let error = sync.syncError {
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 {
Button(role: .destructive) {
Task { await auth.logout() }
@@ -571,11 +572,37 @@ struct SettingsView: View {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
+
Section("App Info") {
- LabeledContent("Version", value: "Phase B")
+ LabeledContent("Version", value: "Phase C")
LabeledContent("Server", value: Constants.baseURL)
}
}
.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())) ?? []
+ let templates = (try? context.fetch(FetchDescriptor())) ?? []
+ let areas = (try? context.fetch(FetchDescriptor())) ?? []
+
+ 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() }
+ }
}
}
diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift
new file mode 100644
index 0000000..c32f269
--- /dev/null
+++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift
@@ -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)
+ }
+}