// 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 @State private var showNewInspection = false /// 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) .toolbar { ToolbarItem(placement: .primaryAction) { Button { showNewInspection = true } label: { Image(systemName: "plus") } } } .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() } } } .sheet(isPresented: $showNewInspection) { StartInspectionView() } .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 // 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.") } } 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 var body: some View { Group { if issues.isEmpty { ContentUnavailableView( "No Issues", systemImage: "exclamationmark.triangle", description: Text("Issues you flag during inspections will appear here.") ) } else { List(issues) { issue in NavigationLink(value: issue) { IssueRowView(issue: issue, context: context) } } } } .navigationTitle("Issues (\(issues.count))") } } 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)") } } 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) } } } } if !issue.photoServerPaths.isEmpty { Section("Photos (\(issue.photoServerPaths.count))") { ForEach(issue.photoServerPaths, id: \.self) { relativePath in AsyncImage(url: URL(string: Constants.baseURL + "/" + relativePath)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) case .failure: Label("Photo unavailable", systemImage: "photo.slash") .foregroundStyle(.secondary) case .empty: HStack(spacing: 8) { ProgressView() Text("Loading…").font(.caption).foregroundStyle(.secondary) } @unknown default: EmptyView() } } } } } } .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: - 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 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 { await auth.logout() } } label: { Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") } } Section("App Info") { LabeledContent("Version", value: "\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"))") LabeledContent("Server", value: Constants.baseURL) } } .navigationTitle("Settings") .alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) { Button("Clear", role: .destructive) { clearCache() } Button("Cancel", role: .cancel) {} } message: { Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.") } } private func clearCache() { // Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto let facilities = (try? context.fetch(FetchDescriptor())) ?? [] let templates = (try? context.fetch(FetchDescriptor())) ?? [] let areas = (try? context.fetch(FetchDescriptor())) ?? [] facilities.forEach { context.delete($0) } templates.forEach { context.delete($0) } areas.forEach { context.delete($0) } try? context.save() cacheCleared = true // Re-pull immediately if online if sync.isOnline { Task { await sync.pullReferenceData() } } } }