Files
JQC_iOS_App/JanitorialQC/Views/Dashboard/DashboardView.swift
T

665 lines
30 KiB
Swift

// 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<LocalInspection> { $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<LocalInspection> { $0.status == "draft" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var draftInspections: [LocalInspection]
@Environment(\.modelContext) private var context
/// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not
/// in the card: the card self-hides, and submitting the last scheduled
/// inspection empties its @Query while the start form is still presented.
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
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(onStart: { target in
scheduledStartTarget = target
})
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()
}
// Start cover for a tapped scheduled inspection. Owned here rather than
// by ScheduledInspectionsCard because that card self-hides the instant
// its last row is removed — which is exactly when this cover is on
// screen (submit deletes the cached schedule row). The ScrollView is
// always present, so the form is never torn down mid-submit.
.fullScreenCover(item: $scheduledStartTarget) { t in
StartInspectionView(
preFillTemplateId: t.templateServerId,
preFillFacilityId: t.facilityServerId,
preFillScheduleId: t.id,
preFillScheduleInstructions: t.instructions
)
}
}
// ── Helpers ────────────────────────────────────────────────────────────
@ViewBuilder
private func statsSection<Content: View>(
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, isModallyPresented: true)
}
}
}
private func templateName(for inspection: LocalInspection) -> String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(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<LocalFacility>())) ?? []
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<NSString, UIImage>()
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
}
}
}
}
}
}