05/02 Phase C

This commit is contained in:
Nguyen Ngo
2026-05-03 08:31:25 -04:00
parent 8bdcbecd73
commit 59e698b5f7
6 changed files with 451 additions and 147 deletions
+20 -19
View File
@@ -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
} }
+31 -10
View File
@@ -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()
+23
View File
@@ -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>
+53 -4
View File
@@ -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)
} }
+138 -111
View File
@@ -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,9 +120,13 @@ 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) {
@@ -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)
} }
} }
@@ -236,7 +240,7 @@ struct StatusBadge: View {
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)
}
}