// Views/Dashboard/DashboardView.swift // ------------------------------------ // Phase C: adds Inspection History tab, polished Settings with cache clear, // and schedules background sync on scene enter background. // // CHANGED (sidebar update): // - Templates removed from sidebar entirely. // - Issues view added for all roles. // - History moved to sit between Pending Sync and Settings. // - Tab identity is now enum-based (SidebarTab) instead of raw Int // so adding/removing tabs never breaks the detail switch. import SwiftUI import SwiftData import Combine // MARK: - SidebarTab enum SidebarTab: Hashable { case myInspections case issues // inspector role only case facilities case pendingSync case history // moved after Pending Sync case settings } struct DashboardView: View { @EnvironmentObject private var auth: AuthManager @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @Environment(\.scenePhase) private var scenePhase @Query( filter: #Predicate { $0.status != "synced" }, sort: \LocalInspection.lastModifiedAt, order: .reverse ) private var myInspections: [LocalInspection] @State private var selectedTab: SidebarTab = .myInspections /// Each sidebar tap refreshes the UUID for that tab, forcing its /// NavigationStack to be destroyed and recreated — even when the tab /// hasn't changed (user is already on it but deep inside a detail view). @State private var tabResetId: [SidebarTab: UUID] = [ .myInspections: UUID(), .issues: UUID(), .facilities: UUID(), .pendingSync: UUID(), .history: UUID(), .settings: UUID(), ] /// Explicit paths for the three tabs that push detail views. /// Resetting these to empty pops the stack to root immediately and reliably. @State private var inspectionsPath = NavigationPath() @State private var issuesPath = NavigationPath() @State private var historyPath = NavigationPath() /// Tap a sidebar tab: reset all navigable paths, then switch to it. private func selectTab(_ tab: SidebarTab) { inspectionsPath = NavigationPath() issuesPath = NavigationPath() historyPath = NavigationPath() tabResetId[tab] = UUID() selectedTab = tab } var body: some View { NavigationSplitView { List { // ── My Inspections ───────────────────────────────────────── Button { selectTab(.myInspections) } label: { HStack { Label("My Inspections", systemImage: "checklist") .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) Spacer() if !myInspections.isEmpty { Text("\(myInspections.count)") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.blue.opacity(0.15)) .clipShape(Capsule()) } } } .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) // ── Issues (all roles) ───────────────────────────────────── Button { selectTab(.issues) } label: { Label("Issues", systemImage: "exclamationmark.triangle") .foregroundStyle(selectedTab == .issues ? .blue : .primary) } .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) // ── Facilities ───────────────────────────────────────────── Button { selectTab(.facilities) } label: { Label("Facilities", systemImage: "building.2") .foregroundStyle(selectedTab == .facilities ? .blue : .primary) } .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) // ── Pending Sync ─────────────────────────────────────────── Button { selectTab(.pendingSync) } label: { HStack { Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) Spacer() if sync.pendingCount > 0 { Text("\(sync.pendingCount)") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.orange.opacity(0.2)) .foregroundStyle(.orange) .clipShape(Capsule()) } } } .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) // ── History (moved — sits between Pending Sync and Settings) Button { selectTab(.history) } label: { Label("History", systemImage: "clock.arrow.circlepath") .foregroundStyle(selectedTab == .history ? .blue : .primary) } .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) // ── Settings ─────────────────────────────────────────────── Button { selectTab(.settings) } label: { Label("Settings", systemImage: "gear") .foregroundStyle(selectedTab == .settings ? .blue : .primary) } .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) } .navigationTitle("JQC Inspector") .listStyle(.sidebar) .safeAreaInset(edge: .bottom) { syncStatusFooter } } detail: { switch selectedTab { case .myInspections: NavigationStack(path: $inspectionsPath) { MyInspectionsView() .navigationDestination(for: LocalInspection.self) { inspection in if inspection.status == "draft" { ExecuteInspectionView(inspection: inspection) } else { CompletedInspectionView(inspection: inspection) } } } case .issues: NavigationStack(path: $issuesPath) { IssuesListView() .navigationDestination(for: LocalIssue.self) { issue in IssueDetailView(issue: issue) } } case .facilities: NavigationStack { FacilitiesListView() } case .pendingSync: NavigationStack { SyncStatusView() } case .history: NavigationStack(path: $historyPath) { InspectionHistoryView() .navigationDestination(for: APIInspectionSummary.self) { inspection in HistoryDetailView(inspection: inspection) } } case .settings: NavigationStack { SettingsView() } } } .task { if sync.isOnline { await sync.triggerSync() } else { sync.updatePendingCount(context: context) } } // Schedule background sync when app is backgrounded .onChange(of: scenePhase) { if scenePhase == .background { scheduleBackgroundSync() } } } private var syncStatusFooter: some View { VStack(spacing: 0) { Divider() HStack(spacing: 8) { Circle() .fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) Text(sync.isOnline ? "Online" : "Offline") .font(.caption) .foregroundStyle(.secondary) Spacer() if sync.isSyncing { ProgressView().scaleEffect(0.7) } else if let lastSync = sync.lastSyncAt { Text("Synced \(lastSync.formatted(.relative(presentation: .named)))") .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.horizontal, 16) .padding(.vertical, 8) } } } // MARK: - My Inspections struct MyInspectionsView: View { @Query( filter: #Predicate { $0.status != "synced" }, sort: \LocalInspection.lastModifiedAt, order: .reverse ) private var inspections: [LocalInspection] @Environment(\.modelContext) private var context @State private var showNewInspection = false // Deletion confirmation state @State private var pendingDelete: LocalInspection? @State private var showDeleteAlert = false var body: some View { Group { if inspections.isEmpty { ContentUnavailableView( "No Inspections", systemImage: "checklist", description: Text("Tap + to start a new inspection.") ) } else { List(inspections) { inspection in NavigationLink(value: inspection) { InspectionRowView(inspection: inspection, context: context) } // Only drafts may be deleted — submitted/pending-sync inspections are kept .swipeActions(edge: .trailing, allowsFullSwipe: false) { if inspection.status == "draft" { Button(role: .destructive) { pendingDelete = inspection showDeleteAlert = true } label: { Label("Delete", systemImage: "trash") } } } } } } .navigationTitle("My Inspections") // Confirmation before deletion — destructive action cannot be undone .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in Button("Delete", role: .destructive) { deleteDraft(inspection) } Button("Cancel", role: .cancel) { pendingDelete = nil } } message: { inspection in Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") } .toolbar { ToolbarItem(placement: .primaryAction) { Button { showNewInspection = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showNewInspection) { StartInspectionView() } } private func draftName(_ inspection: LocalInspection) -> String { let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate return (try? context.fetch( FetchDescriptor( predicate: #Predicate { $0.serverId == templateId } ) ).first?.name) ?? "this inspection" } private func deleteDraft(_ inspection: LocalInspection) { // Delete associated pending photos from disk and SwiftData for photo in inspection.pendingPhotos { try? FileManager.default.removeItem(atPath: photo.localFilePath) context.delete(photo) } // Delete associated local issues for issue in inspection.localIssues { for path in issue.photoLocalPaths { try? FileManager.default.removeItem(atPath: path) } context.delete(issue) } context.delete(inspection) try? context.save() pendingDelete = nil } } struct InspectionRowView: View { let inspection: LocalInspection let context: ModelContext private var facilityName: String { let id = inspection.facilityServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Facility" } private var templateName: String { let id = inspection.templateServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Template" } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(templateName).font(.headline) Spacer() StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } Text(facilityName).font(.callout).foregroundStyle(.secondary) HStack { Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) .font(.caption2).foregroundStyle(.tertiary) if let score = inspection.overallScore { Spacer() Text(String(format: "%.1f%%", score)) .font(.caption).fontWeight(.medium) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } // ── Follow-up badge ──────────────────────────────────────────── if inspection.followUpRequired { HStack(spacing: 4) { Image(systemName: "exclamationmark.arrow.circlepath") .font(.caption2) Text("Follow-up Required") .font(.caption2.bold()) } .padding(.horizontal, 8).padding(.vertical, 3) .background(Color.orange.opacity(0.15)) .foregroundStyle(.orange) .clipShape(Capsule()) } } .padding(.vertical, 4) } } struct StatusBadge: View { let status: String let syncStatus: String var label: String { switch status { case "draft": return "Draft" case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed" case "failed": return "Sync Failed" default: return status.capitalized } } var color: Color { switch status { case "draft": return .blue case "completed": return syncStatus == "pending" ? .orange : .green case "failed": return .red default: return .secondary } } var body: some View { Text(label) .font(.caption2) .padding(.horizontal, 8).padding(.vertical, 3) .background(color.opacity(0.15)) .foregroundStyle(color) .clipShape(Capsule()) } } // MARK: - Completed Inspection (read-only) struct CompletedInspectionView: View { let inspection: LocalInspection @Environment(\.modelContext) private var context @State private var showReInspect = false private var templateName: String { let id = inspection.templateServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Inspection" } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { // ── Follow-up required banner ────────────────────────────── if inspection.followUpRequired { HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.arrow.circlepath") .foregroundStyle(.orange) .font(.title3) VStack(alignment: .leading, spacing: 4) { Text("Follow-up Inspection Required") .font(.callout.bold()) .foregroundStyle(.orange) if let note = inspection.followUpNote, !note.isEmpty { Text(note) .font(.callout) .foregroundStyle(.secondary) } Button { showReInspect = true } label: { Label("Start Re-inspection", systemImage: "arrow.uturn.right.circle.fill") .font(.callout.bold()) } .buttonStyle(.borderedProminent) .tint(.orange) .padding(.top, 4) } } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) } // ── Is a re-inspection — parent link ─────────────────────── if let parentId = inspection.parentServerId { HStack(spacing: 10) { Image(systemName: "arrow.uturn.right.circle") .foregroundStyle(.secondary) Text("Re-inspection of inspection #\(parentId)") .font(.callout) .foregroundStyle(.secondary) } .padding(.horizontal) } GroupBox { VStack(alignment: .leading, spacing: 8) { if let score = inspection.overallScore { HStack { Text("Overall Score").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(String(format: "%.1f%%", score)) .font(.title2.bold()) .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) } } if let completedAt = inspection.completedAt { HStack { Text("Completed").font(.subheadline).foregroundStyle(.secondary) Spacer() Text(completedAt.formatted(date: .abbreviated, time: .shortened)) .font(.callout) } } HStack { Text("Sync Status").font(.subheadline).foregroundStyle(.secondary) Spacer() StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) } if let error = inspection.syncErrorMessage { Text("Error: \(error)").font(.caption).foregroundStyle(.red) } } } .padding(.horizontal) if !inspection.localIssues.isEmpty { VStack(alignment: .leading, spacing: 8) { Text("Flagged Issues (\(inspection.localIssues.count))") .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) VStack(alignment: .leading, spacing: 2) { Text(issue.severity.capitalized) .font(.caption.bold()).foregroundStyle(.secondary) Text(issue.issueDescription).font(.callout) } } .padding(.horizontal) } } } } .padding(.vertical) } .navigationTitle(templateName) .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showReInspect) { StartInspectionView( preFillTemplateId: inspection.templateServerId, preFillFacilityId: inspection.facilityServerId, parentServerId: inspection.serverId, parentLocalId: inspection.localId ) } } } // MARK: - Sync Status View struct SyncStatusView: View { @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context @Query( filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, sort: \LocalInspection.createdAt ) private var pendingInspections: [LocalInspection] @Query( filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, sort: \LocalIssue.createdAt ) private var pendingIssues: [LocalIssue] var body: some View { List { Section("Status") { HStack { Circle().fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) Text(sync.isOnline ? "Online" : "Offline") } if let lastSync = sync.lastSyncAt { LabeledContent("Last Sync", value: lastSync.formatted(date: .abbreviated, time: .shortened)) } if sync.isSyncing { HStack { ProgressView() Text("Syncing…").foregroundStyle(.secondary) } } if let error = sync.syncError { Text(error).foregroundStyle(.red).font(.callout) } Button { Task { await sync.triggerSync() } } label: { Label("Sync Now", systemImage: "arrow.clockwise") } .disabled(!sync.isOnline || sync.isSyncing) } if !pendingInspections.isEmpty { Section("Pending Inspections (\(pendingInspections.count))") { ForEach(pendingInspections) { insp in SyncRowView(title: "Inspection", status: insp.syncStatus, retryCount: insp.syncRetryCount, error: insp.syncErrorMessage, date: insp.createdAt) } } } if !pendingIssues.isEmpty { Section("Pending Issues (\(pendingIssues.count))") { ForEach(pendingIssues) { issue in SyncRowView(title: "\(issue.severity.capitalized) Issue", status: issue.syncStatus, retryCount: issue.syncRetryCount, error: issue.syncErrorMessage, date: issue.createdAt) } } } if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing { Section { Label("All items synced.", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) } } } .navigationTitle("Pending Sync") } } struct SyncRowView: View { let title: String let status: String let retryCount: Int let error: String? let date: Date var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(title).font(.callout) Spacer() Text(status.capitalized).font(.caption2) .foregroundStyle(status == "failed" ? .red : .orange) } Text(date.formatted(date: .abbreviated, time: .shortened)) .font(.caption2).foregroundStyle(.tertiary) if let err = error { Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2) } if retryCount > 0 { Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.secondary) } } .padding(.vertical, 2) } } // MARK: - Facilities struct FacilitiesListView: View { @Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility] 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.")) } else { List(facilities) { facility in VStack(alignment: .leading, spacing: 4) { Text(facility.name).font(.headline) if !facility.address.isEmpty { Text(facility.address).font(.caption).foregroundStyle(.secondary) } if facility.projectName != "No Contract" { Text(facility.projectName).font(.caption2).foregroundStyle(.blue) } Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.tertiary) } .padding(.vertical, 4) } } } .navigationTitle("Facilities (\(facilities.count))") } } // MARK: - Issues (Inspector) // Shows issues flagged by this inspector across all inspections. // Read-only list — tapping shows description and sync status detail. struct IssuesListView: View { @Query( sort: \LocalIssue.createdAt, order: .reverse ) private var issues: [LocalIssue] @Environment(\.modelContext) private var context @State private var showNewIssue = false var body: some View { Group { if issues.isEmpty { ContentUnavailableView( "No Issues", systemImage: "exclamationmark.triangle", description: Text("Tap + to log a new issue, or flag one during an inspection.") ) } else { List(issues) { issue in NavigationLink(value: issue) { IssueRowView(issue: issue, context: context) } } } } .navigationTitle("Issues (\(issues.count))") .toolbar { ToolbarItem(placement: .primaryAction) { Button { showNewIssue = true } label: { Image(systemName: "plus") } } } .sheet(isPresented: $showNewIssue) { StandaloneIssueView() } } } struct IssueRowView: View { let issue: LocalIssue let context: ModelContext private var facilityName: String { let id = issue.facilityServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Facility" } private var severityColor: Color { switch issue.severity { case "critical": return .red case "high": return .orange case "medium": return .yellow default: return .blue } } var body: some View { HStack(alignment: .top, spacing: 12) { Circle() .fill(severityColor) .frame(width: 10, height: 10) .padding(.top, 5) VStack(alignment: .leading, spacing: 3) { HStack { Text(issue.severity.capitalized) .font(.caption.bold()) .foregroundStyle(severityColor) Spacer() StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) } Text(issue.issueDescription) .font(.callout) .lineLimit(2) Text(facilityName) .font(.caption) .foregroundStyle(.secondary) Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened)) .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } struct IssueDetailView: View { let issue: LocalIssue @Environment(\.modelContext) private var context @EnvironmentObject private var sync: SyncManager @State private var isLoadingStatus = false @State private var isUpdatingStatus = false @State private var statusError: String? @State private var showStatusPicker = false private var facilityName: String { let id = issue.facilityServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Unknown Facility" } private var severityColor: Color { switch issue.severity { case "critical": return .red case "high": return .orange case "medium": return .yellow default: return .blue } } /// Inspector can update status only if the issue has synced (has a serverId) /// and we are online. Admins/directors can always update when online. private var canUpdateStatus: Bool { guard sync.isOnline, issue.serverId != nil else { return false } let role = AuthManager.shared.currentUserRole return role == "admin" || role == "director" || role == "inspector" } private let allStatuses: [(value: String, label: String, color: Color)] = [ ("open", "Open", .blue), ("in_progress", "In Progress", .orange), ("pending_verification", "Pending Verification", .purple), ("resolved", "Resolved", .green), ] private func statusColor(for status: String) -> Color { allStatuses.first { $0.value == status }?.color ?? .secondary } private func statusLabel(for status: String) -> String { allStatuses.first { $0.value == status }?.label ?? status.replacingOccurrences(of: "_", with: " ").capitalized } var body: some View { List { Section("Issue Details") { LabeledContent("Severity") { Text(issue.severity.capitalized) .foregroundStyle(severityColor) .fontWeight(.semibold) } LabeledContent("Facility", value: facilityName) LabeledContent("Reported", value: issue.createdAt.formatted( date: .long, time: .shortened)) // ── Issue Status ─────────────────────────────────────────── LabeledContent("Issue Status") { HStack(spacing: 6) { if isLoadingStatus { ProgressView().scaleEffect(0.7) } else { Text(statusLabel(for: issue.issueStatus)) .foregroundStyle(statusColor(for: issue.issueStatus)) .fontWeight(.semibold) } } } // ── Status picker (online + synced only) ─────────────────── if canUpdateStatus { if isUpdatingStatus { HStack { ProgressView() Text("Updating…").foregroundStyle(.secondary).font(.callout) } } else { Picker("Change Status", selection: Binding( get: { issue.issueStatus }, set: { newStatus in Task { await changeStatus(to: newStatus) } } )) { ForEach(allStatuses, id: \.value) { s in Text(s.label).tag(s.value) } } .pickerStyle(.menu) .tint(statusColor(for: issue.issueStatus)) } } if let err = statusError { Text(err).font(.caption).foregroundStyle(.red) } } Section("Description") { Text(issue.issueDescription) .font(.callout) } Section("Sync Status") { LabeledContent("Sync") { StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) } if let err = issue.syncErrorMessage { Text(err).font(.caption).foregroundStyle(.red) } if issue.syncRetryCount > 0 { LabeledContent("Retry Count", value: "\(issue.syncRetryCount)") } } // ── Photo display logic ──────────────────────────────────────── // While the issue is pending (not yet submitted to the server), // show only local photos from disk — photoServerPaths may be // partially populated from mid-sync photo uploads, causing a mix // of working and broken images. Once synced, photoLocalPaths is // cleared and only the server paths section renders. if issue.syncStatus != "synced" { // Pending / failed: show local files only if !issue.photoLocalPaths.isEmpty { Section("Photos (\(issue.photoLocalPaths.count))") { ForEach(issue.photoLocalPaths, id: \.self) { path in if let img = UIImage(contentsOfFile: path) { Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) } else { Label("Photo pending upload", systemImage: "photo") .foregroundStyle(.secondary) } } } } } else { // Synced: show server photos only if !issue.photoServerPaths.isEmpty { Section("Photos (\(issue.photoServerPaths.count))") { ForEach(issue.photoServerPaths, id: \.self) { relativePath in RetryablePhotoView( url: URL(string: ServerConfig.current + "/static/" + relativePath) ) } } } } } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) .task { await refreshStatusFromServer() } } // ── Fetch fresh status from server ──────────────────────────────────── private func refreshStatusFromServer() async { guard sync.isOnline, let sid = issue.serverId else { return } isLoadingStatus = true statusError = nil defer { isLoadingStatus = false } do { let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid) issue.issueStatus = detail.status try? context.save() } catch { // Non-fatal — show cached status silently } } // ── Push status change to server ────────────────────────────────────── private func changeStatus(to newStatus: String) async { guard let sid = issue.serverId else { return } isUpdatingStatus = true statusError = nil defer { isUpdatingStatus = false } do { let confirmed = try await APIClient.shared.updateIssueStatus( issueId: sid, status: newStatus ) issue.issueStatus = confirmed try? context.save() } catch { statusError = error.localizedDescription } } } // MARK: - Standalone Issue Creation // Allows inspectors to log an issue directly from the Issues page, // without being inside an active inspection. The issue is created with // inspectionLocalId == "" and synced to the server via processIssueQueue. struct StandaloneIssueView: View { @Environment(\.modelContext) private var context @Environment(\.dismiss) private var dismiss @EnvironmentObject private var sync: SyncManager @Query(sort: \LocalFacility.name) private var facilities: [LocalFacility] // ── Contract → Facility cascade (mirrors StartInspectionView) ───────── @State private var selectedProjectId: Int? = nil @State private var selectedFacilityId: Int? = nil @State private var severity = "medium" @State private var description = "" @State private var photos: [(image: UIImage, path: String)] = [] @State private var showCamera = false @State private var showLibrary = false @State private var showBanner = false private let maxPhotos = 5 private let severities = ["low", "medium", "high", "critical"] private var cameraAvailable: Bool { UIImagePickerController.isSourceTypeAvailable(.camera) } private var remainingSlots: Int { maxPhotos - photos.count } /// Unique contracts derived from cached facilities, sorted by name. private var contracts: [(id: Int, name: String)] { var seen = Set() var result: [(id: Int, name: String)] = [] for f in facilities { if seen.insert(f.projectId).inserted { result.append((id: f.projectId, name: f.projectName)) } } return result.sorted { $0.name < $1.name } } /// Facilities belonging to the selected contract. /// Facilities for the selected contract, deduplicated by serverId. private var filteredFacilities: [LocalFacility] { guard let pid = selectedProjectId else { return [] } var seen = Set() return facilities .filter { $0.projectId == pid } .filter { seen.insert($0.serverId).inserted } } private var canSubmit: Bool { selectedFacilityId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { NavigationStack { Form { // ── Contract picker ──────────────────────────────────────── Section("Contract") { if contracts.isEmpty { Text("No contracts available. Sync required.") .foregroundStyle(.secondary).font(.callout) } else { Picker("Contract", selection: $selectedProjectId) { Text("Select a contract…").tag(Optional(nil)) ForEach(contracts, id: \.id) { contract in Text(contract.name).tag(Optional(contract.id)) } } .pickerStyle(.navigationLink) .onChange(of: selectedProjectId) { // Reset facility when contract changes let facilityBelongsToContract = facilities.contains { $0.serverId == selectedFacilityId && $0.projectId == selectedProjectId } if !facilityBelongsToContract { selectedFacilityId = nil } } } } // ── Facility picker (gated on contract selection) ────────── if selectedProjectId != nil { Section("Facility") { if filteredFacilities.isEmpty { Text("No facilities in this contract.") .foregroundStyle(.secondary).font(.callout) } else { Picker("Facility", selection: $selectedFacilityId) { Text("Select a facility…").tag(Optional(nil)) ForEach(filteredFacilities) { facility in Text(facility.name).tag(Optional(facility.serverId)) } } .pickerStyle(.navigationLink) } } } // ── Severity ─────────────────────────────────────────────── Section("Severity") { Picker("Severity", selection: $severity) { ForEach(severities, id: \.self) { s in Text(s.capitalized).tag(s) } } .pickerStyle(.segmented) } // ── Description ──────────────────────────────────────────── Section("Description") { TextEditor(text: $description) .frame(minHeight: 100) } // ── Photos ───────────────────────────────────────────────── Section { if !photos.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(photos.indices, id: \.self) { i in ZStack(alignment: .topTrailing) { Image(uiImage: photos[i].image) .resizable() .scaledToFill() .frame(width: 100, height: 100) .clipShape(RoundedRectangle(cornerRadius: 10)) Button { removePhoto(at: i) } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .symbolRenderingMode(.palette) .foregroundStyle(.white, .black.opacity(0.7)) } .offset(x: 6, y: -6) } } } .padding(.vertical, 6) } } if remainingSlots > 0 { let countLabel = photos.isEmpty ? "Up to \(maxPhotos) photos" : "\(photos.count)/\(maxPhotos) — \(remainingSlots) remaining" Text(countLabel).font(.caption).foregroundStyle(.secondary) if cameraAvailable { Button { showCamera = true } label: { HStack { Image(systemName: "camera.fill").font(.title3).frame(width: 36) Text("Take Photo") Spacer() } .padding(.vertical, 10).contentShape(Rectangle()) } .foregroundStyle(.primary) } Button { showLibrary = true } label: { HStack { Image(systemName: "photo.on.rectangle.angled").font(.title3).frame(width: 36) Text("Choose from Library") Spacer() } .padding(.vertical, 10).contentShape(Rectangle()) } .foregroundStyle(.primary) } } header: { Text("Photos (Optional)") } footer: { if !photos.isEmpty { Text("Tap × on a photo to remove it.").font(.caption) } } // ── Offline notice ───────────────────────────────────────── if !sync.isOnline { Section { Label("You\'re offline — this issue will sync automatically.", systemImage: "wifi.slash") .font(.callout).foregroundStyle(.orange) } } } .navigationTitle("New Issue") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Submit") { submitIssue() } .disabled(!canSubmit) .fontWeight(.semibold) } } .overlay(alignment: .top) { if showBanner { HStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.title2).foregroundStyle(.green) VStack(alignment: .leading, spacing: 2) { Text("Issue Logged").font(.headline) Text(sync.isOnline ? "Submitted to server." : "Saved — will sync when online.") .font(.caption).foregroundStyle(.secondary) } Spacer() } .padding(16) .background(Color(.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.1), radius: 8, y: 4) .padding(.horizontal, 24).padding(.top, 8) .transition(.move(edge: .top).combined(with: .opacity)) .zIndex(10) } } .animation(.spring(duration: 0.35), value: showBanner) .fullScreenCover(isPresented: $showCamera) { CameraPickerView(image: .constant(nil), onSelected: appendPhoto) .ignoresSafeArea() } .sheet(isPresented: $showLibrary) { MultiLibraryPickerView(selectionLimit: remainingSlots, onSelected: appendPhotos) } } } // ── Photo helpers ───────────────────────────────────────────────────── private func appendPhoto(_ img: UIImage) { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { return } photos.append((image: img, path: path)) } private func appendPhotos(_ images: [UIImage]) { for img in images { guard photos.count < maxPhotos, let path = savePhotoToDisk(img) else { break } photos.append((image: img, path: path)) } } private func removePhoto(at index: Int) { guard index < photos.count else { return } try? FileManager.default.removeItem(atPath: photos[index].path) photos.remove(at: index) } private func savePhotoToDisk(_ img: UIImage) -> String? { guard let data = img.jpegData(compressionQuality: 0.8) else { return nil } let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) let fileURL = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") try? data.write(to: fileURL) return fileURL.path } // ── Submit ──────────────────────────────────────────────────────────── private func submitIssue() { guard let facilityId = selectedFacilityId else { return } let issue = LocalIssue( inspectionLocalId: "", // standalone — not tied to any inspection facilityServerId: facilityId, severity: severity, description: description.trimmingCharacters(in: .whitespaces) ) issue.photoLocalPaths = photos.map(\.path) context.insert(issue) for photo in photos { let pending = PendingPhoto( localFilePath: photo.path, entityType: "issue", entityLocalId: issue.localId ) context.insert(pending) } try? context.save() if sync.isOnline { Task { await sync.triggerSync() } } withAnimation { showBanner = true } Task { try? await Task.sleep(for: .seconds(2)) dismiss() } } } // MARK: - Retryable Photo /// Loads a server photo via AsyncImage with a tap-to-retry failure state. /// AsyncImage has no built-in retry — once it enters .failure it stays there /// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and /// recreate the AsyncImage, triggering a fresh network load. struct RetryablePhotoView: View { let url: URL? @State private var reloadToken = UUID() var body: some View { AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) case .failure: VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.secondary) Text("Photo unavailable") .font(.caption) .foregroundStyle(.secondary) Button { reloadToken = UUID() } label: { Label("Retry", systemImage: "arrow.clockwise") .font(.caption) } .buttonStyle(.bordered) .controlSize(.small) } .frame(maxWidth: .infinity) .padding(.vertical, 12) case .empty: HStack(spacing: 8) { ProgressView() Text("Loading…").font(.caption).foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) @unknown default: EmptyView() } } .id(reloadToken) } } // 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.")) } else { List(templates) { template in VStack(alignment: .leading, spacing: 4) { Text(template.name).font(.headline) if !template.templateDescription.isEmpty { Text(template.templateDescription) .font(.caption).foregroundStyle(.secondary).lineLimit(2) } HStack { if !template.frequency.isEmpty { Label(template.frequencyLabel, systemImage: "clock") .font(.caption2).foregroundStyle(.blue) } Spacer() Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")") .font(.caption2).foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } } .navigationTitle("Templates (\(templates.count))") } } // 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 @State private var settingsServer: ServerOption = ServerConfig.selectedOption @State private var pendingServer: ServerOption? = nil @State private var showServerSwitchAlert = false var body: some View { List { Section("Account") { LabeledContent("Username", value: auth.currentUsername) LabeledContent("Role", value: auth.currentUserRole.capitalized) } Section("Sync") { 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 { // Clear server-pulled issues before logging out so stale // records from a previous server/domain don't persist into // the next session. Device-created pending issues are preserved. clearServerPulledData() sync.resetNotificationPoller() await auth.logout() } } label: { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") } } Section { Picker("Server", selection: $settingsServer) { ForEach(ServerOption.allCases, id: \.self) { option in Text(option.displayName).tag(option) } } .pickerStyle(.segmented) .onChange(of: settingsServer) { _, newValue in // Don't commit yet — ask user to confirm logout first. // Revert the picker visually until confirmed. pendingServer = newValue settingsServer = ServerConfig.selectedOption // snap back showServerSwitchAlert = true } } header: { Text("Server") } footer: { Text(ServerConfig.current) .font(.caption2) .foregroundStyle(.tertiary) } Section("App Info") { LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))") } } .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.") } .alert("Switch Server?", isPresented: $showServerSwitchAlert) { Button("Switch & Log Out", role: .destructive) { if let chosen = pendingServer { ServerConfig.select(chosen) settingsServer = chosen pendingServer = nil Task { clearServerPulledData() sync.resetNotificationPoller() await auth.logout() } } } Button("Cancel", role: .cancel) { pendingServer = nil } } message: { if let chosen = pendingServer { Text("Switching to \(chosen.displayName) will log you out. All cached server data will be cleared. You will need to log in again.") } } } 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() } } } /// Delete every LocalIssue that has ever been assigned a serverId. /// This covers two categories: /// 1. Server-pulled assigned issues (inspectionLocalId == "", syncStatus == "synced") /// 2. Inspector-created issues that already synced (inspectionLocalId != "", serverId != nil) /// — their serverIds are meaningless on a different server, so they must go too. /// The only records preserved are truly pending device-created issues /// (serverId == nil, syncStatus == "pending") that have never reached any server. private func clearServerPulledData() { let allIssues = (try? context.fetch(FetchDescriptor())) ?? [] allIssues .filter { $0.serverId != nil } .forEach { context.delete($0) } try? context.save() } }