// Views/Dashboard/DashboardView.swift // ------------------------------------ // Phase C: adds Inspection History tab, polished Settings with cache clear, // and schedules background sync on scene enter background. import SwiftUI import SwiftData import Combine 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 = 0 @State private var showNewInspection = false var body: some View { NavigationSplitView { List { // My Inspections Button { selectedTab = 0 } label: { HStack { Label("My Inspections", systemImage: "checklist") .foregroundStyle(selectedTab == 0 ? .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 == 0 ? Color.blue.opacity(0.1) : Color.clear) // History Button { selectedTab = 1 } label: { Label("History", systemImage: "clock.arrow.circlepath") .foregroundStyle(selectedTab == 1 ? .blue : .primary) } .listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear) // Facilities Button { selectedTab = 2 } label: { Label("Facilities", systemImage: "building.2") .foregroundStyle(selectedTab == 2 ? .blue : .primary) } .listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear) // Templates Button { selectedTab = 3 } label: { Label("Templates", systemImage: "doc.text") .foregroundStyle(selectedTab == 3 ? .blue : .primary) } .listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear) // Pending Sync Button { selectedTab = 4 } label: { HStack { Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") .foregroundStyle(selectedTab == 4 ? .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 == 4 ? Color.blue.opacity(0.1) : Color.clear) // Settings Button { selectedTab = 5 } label: { Label("Settings", systemImage: "gear") .foregroundStyle(selectedTab == 5 ? .blue : .primary) } .listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear) } .navigationTitle("JQC Inspector") .listStyle(.sidebar) .toolbar { ToolbarItem(placement: .primaryAction) { Button { showNewInspection = true } label: { Image(systemName: "plus") } } } .safeAreaInset(edge: .bottom) { syncStatusFooter } } detail: { // CHANGED: each tab is wrapped in its own NavigationStack. // Without this, NavigationLink pushes from MyInspectionsView accumulate // on a shared implicit stack — switching sidebar tabs does not clear // the pushed ExecuteInspectionView, leaving the form stuck on screen. switch selectedTab { case 0: NavigationStack { MyInspectionsView() } case 1: NavigationStack { InspectionHistoryView() } case 2: NavigationStack { FacilitiesListView() } case 3: NavigationStack { TemplatesListView() } case 4: NavigationStack { SyncStatusView() } default: 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 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 { if inspection.status == "draft" { ExecuteInspectionView(inspection: inspection) } else { CompletedInspectionView(inspection: inspection) } } label: { InspectionRowView(inspection: inspection, context: context) } } } } .navigationTitle("My Inspections") } } 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) } } } .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 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) { 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) } } // 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: - 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: "Phase C") LabeledContent("Server", value: Constants.baseURL) } } .navigationTitle("Settings") .alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) { Button("Clear", role: .destructive) { clearCache() } Button("Cancel", role: .cancel) {} } message: { Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.") } } private func clearCache() { // Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto let facilities = (try? context.fetch(FetchDescriptor())) ?? [] let templates = (try? context.fetch(FetchDescriptor())) ?? [] let areas = (try? context.fetch(FetchDescriptor())) ?? [] facilities.forEach { context.delete($0) } templates.forEach { context.delete($0) } areas.forEach { context.delete($0) } try? context.save() cacheCleared = true // Re-pull immediately if online if sync.isOnline { Task { await sync.pullReferenceData() } } } }