// 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 import MessageUI // 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 // Draft inspections — shown as a resume banner at the top of the dashboard // so the inspector never has to hunt through My Inspections to find an // in-progress form they left open. @Query( filter: #Predicate { $0.status == "draft" }, sort: \LocalInspection.lastModifiedAt, order: .reverse ) private var draftInspections: [LocalInspection] @Environment(\.modelContext) private var context var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { // ── Draft Resume Banner ──────────────────────────────────── if !draftInspections.isEmpty { DraftResumeBanner(drafts: draftInspections, context: context) } // ── Scheduled Inspections (phase36) ──────────────────────── // Planned/recurring assignments for this inspector. Self-hides // when there are none. Tap a row to start it (facility + // template preselected). ScheduledInspectionsCard() 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: - Draft Resume Banner // Shown on the dashboard when the inspector has one or more in-progress // (draft) inspections. Tapping a draft opens ExecuteInspectionView as a // full-screen sheet — avoids cross-NavigationStack linking since the // dashboard and My Inspections stacks are independent. struct DraftResumeBanner: View { let drafts: [LocalInspection] let context: ModelContext @State private var selectedDraft: LocalInspection? = nil var body: some View { VStack(alignment: .leading, spacing: 8) { Label(drafts.count == 1 ? "Inspection in progress" : "\(drafts.count) inspections in progress", systemImage: "pencil.and.list.clipboard") .font(.subheadline.bold()) .foregroundStyle(.white) ForEach(drafts) { draft in Button { selectedDraft = draft } label: { HStack { VStack(alignment: .leading, spacing: 2) { Text(templateName(for: draft)) .font(.callout.bold()) .foregroundStyle(.white) Text(facilityName(for: draft)) .font(.caption) .foregroundStyle(.white.opacity(0.85)) Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))") .font(.caption2) .foregroundStyle(.white.opacity(0.70)) } Spacer() Label("Resume", systemImage: "play.fill") .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 10).padding(.vertical, 5) .background(Color.white.opacity(0.25)) .clipShape(Capsule()) } .padding(10) .background(Color.white.opacity(0.12)) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } } .padding(14) .background(Color.blue.gradient) .clipShape(RoundedRectangle(cornerRadius: 14)) .fullScreenCover(item: $selectedDraft) { draft in // Wrap in NavigationStack so ExecuteInspectionView's toolbar // and dismiss work correctly when presented as a sheet. NavigationStack { ExecuteInspectionView(inspection: draft) } } } private func templateName(for inspection: LocalInspection) -> String { let id = inspection.templateServerId return (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) ).first?.name) ?? "Inspection" } private func facilityName(for inspection: LocalInspection) -> String { let id = inspection.facilityServerId let all = (try? context.fetch(FetchDescriptor())) ?? [] return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility" } } // 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. // MARK: - PhotoCache // Simple NSCache-backed in-memory image cache keyed by URL string. // Prevents RetryablePhotoView from re-downloading the same photo on every // view appearance (AsyncImage only caches within a single URLSession load; // revisiting IssueDetailView or scrolling the inspection history starts a // fresh download). Cache entries are evicted automatically by the OS under // memory pressure — no manual lifetime management needed. final class PhotoCache { static let shared = PhotoCache() private let cache = NSCache() private init() { cache.countLimit = 150 // max images in memory cache.totalCostLimit = 80_000_000 // ~80 MB total } func get(_ url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) } func set(_ image: UIImage, for url: URL) { cache.setObject(image, forKey: url.absoluteString as NSString, cost: Int(image.size.width * image.size.height * 4)) } } struct RetryablePhotoView: View { let url: URL? @State private var reloadToken = UUID() @State private var cached: UIImage? = nil var body: some View { Group { if let img = cached { // Cache hit — instant display, no spinner, no network Image(uiImage: img) .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) } else { AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .clipShape(RoundedRectangle(cornerRadius: 8)) .onAppear { // Store into cache so next appearance is instant if let url, let ui = ImageRenderer(content: image).uiImage { PhotoCache.shared.set(ui, for: url) cached = ui } } 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) .onAppear { // Check cache before AsyncImage fires a network request if let url, let img = PhotoCache.shared.get(url) { cached = img } } } } } }