// 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 dashboard // landing page — KPI stats card case myInspections case issues case facilities case pendingSync case history case notifications // in-app notification inbox 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 = .dashboard /// 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] = [ .dashboard: UUID(), .myInspections: UUID(), .issues: UUID(), .facilities: UUID(), .pendingSync: UUID(), .history: UUID(), .notifications: 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 { // ── Dashboard ────────────────────────────────────────────── Button { selectTab(.dashboard) } label: { Label("Dashboard", systemImage: "chart.bar.xaxis") .foregroundStyle(selectedTab == .dashboard ? .blue : .primary) } .listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear) // ── 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) // ── Notifications ───────────────────────────────────────── Button { selectTab(.notifications) sync.markNotificationsViewed() } label: { HStack { Label("Notifications", systemImage: "bell") .foregroundStyle(selectedTab == .notifications ? .blue : .primary) Spacer() if sync.unreadNotificationCount > 0 { Text("\(min(sync.unreadNotificationCount, 99))") .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(Color.red.opacity(0.85)) .foregroundStyle(.white) .clipShape(Capsule()) } } } .listRowBackground(selectedTab == .notifications ? 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 .dashboard: NavigationStack { DashboardStatsView() } 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 .notifications: NavigationStack { NotificationsView() } 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: - Dashboard Stats View // Shows inspector-scoped KPI cards fetched from GET /api/v1/stats/dashboard. // Data is refreshed on every triggerSync() via SyncManager.fetchDashboardStats(). struct DashboardStatsView: View { @EnvironmentObject private var sync: SyncManager var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { if let stats = sync.dashboardStats { // ── Today ────────────────────────────────────────────── statsSection(title: "Today") { HStack(spacing: 12) { statTile( value: "\(stats.todayInspections)", label: "Inspections", icon: "checklist", color: .blue ) statTile( value: "\(stats.completedToday)", label: "Completed", icon: "checkmark.circle.fill", color: .green ) } } // ── Issues ───────────────────────────────────────────── statsSection(title: "Issues") { HStack(spacing: 12) { statTile( value: "\(stats.openIssues)", label: "Open / In Progress", icon: "exclamationmark.triangle", color: .orange ) statTile( value: "\(stats.pendingFollowups)", label: "Pending Follow-ups", icon: "exclamationmark.arrow.circlepath", color: stats.pendingFollowups > 0 ? .orange : .secondary ) } } // ── SLA ──────────────────────────────────────────────── if stats.slaBreached > 0 || stats.slaAtRisk > 0 { statsSection(title: "SLA") { HStack(spacing: 12) { statTile( value: "\(stats.slaBreached)", label: "Breached", icon: "xmark.circle.fill", color: stats.slaBreached > 0 ? .red : .secondary ) statTile( value: "\(stats.slaAtRisk)", label: "At Risk", icon: "clock.badge.exclamationmark", color: stats.slaAtRisk > 0 ? .orange : .secondary ) } } } // ── Severity breakdown ───────────────────────────────── if stats.openIssues > 0 { statsSection(title: "Open Issues by Severity") { HStack(spacing: 8) { if stats.severityCritical > 0 { severityTile(count: stats.severityCritical, label: "Critical", color: .red) } if stats.severityHigh > 0 { severityTile(count: stats.severityHigh, label: "High", color: .orange) } if stats.severityMedium > 0 { severityTile(count: stats.severityMedium, label: "Medium", color: .yellow) } if stats.severityLow > 0 { severityTile(count: stats.severityLow, label: "Low", color: .blue) } } } } // ── Score ────────────────────────────────────────────── statsSection(title: "Performance (30 days)") { if let avg = stats.avgScore30d { let color: Color = avg >= 80 ? .green : avg >= 60 ? .orange : .red HStack(spacing: 16) { Text(String(format: "%.1f%%", avg)) .font(.system(size: 48, weight: .bold, design: .rounded)) .foregroundStyle(color) VStack(alignment: .leading, spacing: 4) { Text("Average Score") .font(.subheadline) .foregroundStyle(.secondary) Text(avg >= 80 ? "Excellent" : avg >= 60 ? "Needs Improvement" : "Below Standard") .font(.caption.bold()) .foregroundStyle(color) } } .padding(.vertical, 4) } else { Text("No completed inspections in the last 30 days.") .font(.callout) .foregroundStyle(.secondary) } } } else if !sync.isOnline { ContentUnavailableView( "Offline", systemImage: "wifi.slash", description: Text("Dashboard stats require an internet connection.") ) } else { VStack(spacing: 16) { ProgressView("Loading stats…") Text("Stats appear after the first sync completes.") .font(.caption) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.top, 60) } } .padding(24) } .navigationTitle("Dashboard") .navigationBarTitleDisplayMode(.large) .refreshable { await sync.fetchDashboardStats() } } // ── Helpers ──────────────────────────────────────────────────────────── @ViewBuilder private func statsSection( title: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 10) { Text(title.uppercased()) .font(.caption.bold()) .foregroundStyle(.secondary) .tracking(1) content() } } private func statTile( value: String, label: String, icon: String, color: Color ) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Image(systemName: icon) .font(.caption) .foregroundStyle(color) Text(label) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } Text(value) .font(.system(size: 32, weight: .bold, design: .rounded)) .foregroundStyle(color) } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background(color.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 12)) } private func severityTile(count: Int, label: String, color: Color) -> some View { VStack(spacing: 4) { Text("\(count)") .font(.system(size: 22, weight: .bold, design: .rounded)) .foregroundStyle(color) Text(label) .font(.caption2.bold()) .foregroundStyle(color.opacity(0.8)) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(color.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 10)) } } // 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 { // Fetch all then filter in Swift — #Predicate with string literals on // LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION // (CLAUDE.md rule 3). Resolved issues are excluded to match the web default. @Query( sort: \LocalIssue.createdAt, order: .reverse ) private var allIssues: [LocalIssue] private var issues: [LocalIssue] { allIssues.filter { $0.issueStatus != "resolved" } } @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 { // Primary: look up from local reference cache (fast, works offline). // Fallback: facilityNameCache persisted from the last server sync. // This covers the case where the user cleared the local cache in Settings // while server-pulled issues are still present. let id = issue.facilityServerId let all = (try? context.fetch(FetchDescriptor())) ?? [] if let name = all.first(where: { $0.serverId == id })?.name { return name } return issue.facilityNameCache ?? "Unknown Facility" } private var severityColor: Color { switch issue.severity { case "critical": return .red case "high": return .orange case "medium": return .yellow default: return .blue } } private func issueStatusColor(_ status: String) -> Color { switch status { case "open": return .blue case "in_progress": return .orange case "pending_verification": return .purple case "resolved": return .green default: return .secondary } } 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() Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized) .font(.caption2) .padding(.horizontal, 6).padding(.vertical, 2) .background(issueStatusColor(issue.issueStatus).opacity(0.15)) .foregroundStyle(issueStatusColor(issue.issueStatus)) .clipShape(Capsule()) 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 // ── Comments ────────────────────────────────────────────────────────── @State private var comments: [APIIssueComment] = [] @State private var isLoadingComments = false @State private var newCommentText = "" @State private var isPostingComment = false @State private var commentError: String? 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) if let area = issue.areaNameCache, !area.isEmpty { LabeledContent("Area", value: area) } if let assignee = issue.assignedToName, !assignee.isEmpty { LabeledContent("Assigned To", value: assignee) } // Use serverReportedAt when available — more accurate than // createdAt (device time) for server-pulled issues. let reportDate = issue.serverReportedAt ?? issue.createdAt LabeledContent("Reported", value: reportDate.formatted( date: .long, time: .shortened)) if let reporter = issue.reportedByName, !reporter.isEmpty { LabeledContent("Reporter", value: reporter) } // ── 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) } // ── Resolution Details (web-staff only — read-only on iPad) ──── if let notes = issue.resultNotes, !notes.isEmpty { Section("Resolution Notes") { Text(notes) .font(.callout) .foregroundStyle(.primary) } } // ── Verification Details ─────────────────────────────────────── if let vAt = issue.verifiedAt { Section("Verification") { LabeledContent("Verified", value: vAt.formatted( date: .long, time: .shortened)) if let note = issue.verificationNote, !note.isEmpty { Text(note) .font(.callout) .foregroundStyle(.secondary) } } } 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) ) } } } } // ── Comments ────────────────────────────────────────────────── if sync.isOnline, issue.serverId != nil { if isLoadingComments { Section("Comments") { HStack { Spacer(); ProgressView(); Spacer() } } } else if !comments.isEmpty { Section("Comments (\(comments.count))") { ForEach(comments) { comment in VStack(alignment: .leading, spacing: 4) { HStack { Text(comment.authorName) .font(.caption.bold()) Spacer() if let date = comment.createdAtDate { Text(date.formatted(.relative(presentation: .named))) .font(.caption2) .foregroundStyle(.tertiary) } } Text(comment.body) .font(.callout) } .padding(.vertical, 2) } } } // ── Add comment ──────────────────────────────────────────── Section("Add Comment") { TextEditor(text: $newCommentText) .frame(minHeight: 60) if let err = commentError { Text(err).font(.caption).foregroundStyle(.red) } Button { Task { await postComment() } } label: { if isPostingComment { HStack { ProgressView(); Text("Posting…") } } else { Label("Post Comment", systemImage: "paperplane.fill") } } .disabled( newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment ) } } } .navigationTitle("Issue Detail") .navigationBarTitleDisplayMode(.inline) .task { await refreshStatusFromServer() await loadComments() } } // ── 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 // Refresh Phase A resolution fields from live server data if let notes = detail.resultNotes { issue.resultNotes = notes } if let vNote = detail.verificationNote { issue.verificationNote = vNote } if let rName = detail.reportedByName { issue.reportedByName = rName } if let fName = detail.facilityName, !fName.isEmpty { issue.facilityNameCache = fName } if let vts = detail.verifiedAt, let date = SyncManager.isoFormatter.date(from: vts) { issue.verifiedAt = date } if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area } if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee } try? context.save() } catch { // Non-fatal — show cached values silently } } // ── Load comments from server ────────────────────────────────────────── private func loadComments() async { guard sync.isOnline, let sid = issue.serverId else { return } isLoadingComments = true defer { isLoadingComments = false } do { comments = try await APIClient.shared.fetchIssueComments(issueId: sid) } catch { // Non-fatal — empty list shown } } // ── Post a new comment ───────────────────────────────────────────────── private func postComment() async { guard let sid = issue.serverId else { return } let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines) guard !body.isEmpty else { return } isPostingComment = true commentError = nil defer { isPostingComment = false } do { _ = try await APIClient.shared.postIssueComment(issueId: sid, body: body) newCommentText = "" // Reload comments so the new one appears comments = try await APIClient.shared.fetchIssueComments(issueId: sid) } catch { commentError = error.localizedDescription } } // ── 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: - Notifications Inbox // Shows the most recent notifications fetched during polling. // Notifications are already marked read on the server by pollNotifications(). struct NotificationsView: View { @EnvironmentObject private var sync: SyncManager var body: some View { Group { if sync.recentNotifications.isEmpty { if !sync.isOnline { ContentUnavailableView( "Offline", systemImage: "wifi.slash", description: Text("Notifications are delivered when you go online.") ) } else { ContentUnavailableView( "No Notifications", systemImage: "bell.slash", description: Text("You\'re all caught up.") ) } } else { List(sync.recentNotifications) { notif in VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top) { Image(systemName: iconName(for: notif.eventType)) .foregroundStyle(iconColor(for: notif.eventType)) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text(notif.title) .font(.callout.bold()) .lineLimit(2) Text(notif.body) .font(.caption) .foregroundStyle(.secondary) .lineLimit(3) } } if let date = SyncManager.isoFormatter.date(from: notif.createdAt) { Text(date.formatted(.relative(presentation: .named))) .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } } .navigationTitle("Notifications") .navigationBarTitleDisplayMode(.large) .onAppear { sync.markNotificationsViewed() } .refreshable { await sync.pollNotifications() sync.markNotificationsViewed() } } private func iconName(for eventType: String?) -> String { switch eventType { case "inspection_completed": return "checkmark.circle.fill" case "issue_flagged": return "exclamationmark.triangle.fill" case "issue_resolved": return "checkmark.seal.fill" case "sla_alert": return "clock.badge.exclamationmark" case "follow_up_required": return "exclamationmark.arrow.circlepath" default: return "bell.fill" } } private func iconColor(for eventType: String?) -> Color { switch eventType { case "inspection_completed": return .green case "issue_flagged": return .orange case "issue_resolved": return .green case "sla_alert": return .red case "follow_up_required": return .orange default: return .blue } } } // 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 { // Do NOT clear server-pulled data on a plain logout — // the user is logging out of the same server, so cached // facilities, issues, and templates are still valid on // their next login. Clearing here leaves the issues list // empty until a full sync succeeds, which breaks offline use. // Server-pulled data is only cleared when switching servers // (see the Switch & Log Out alert below). 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() } }