2337 lines
100 KiB
Swift
2337 lines
100 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
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
|
||
// ── Draft Resume Banner ────────────────────────────────────
|
||
if !draftInspections.isEmpty {
|
||
DraftResumeBanner(drafts: draftInspections, context: context)
|
||
}
|
||
|
||
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<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)
|
||
}
|
||
}
|
||
}
|
||
|
||
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: - My Inspections
|
||
|
||
struct MyInspectionsView: View {
|
||
|
||
@Query(
|
||
filter: #Predicate<LocalInspection> { $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<LocalTemplate>(
|
||
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<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||
).first?.name) ?? "Unknown Facility"
|
||
}
|
||
|
||
private var templateName: String {
|
||
let id = inspection.templateServerId
|
||
return (try? context.fetch(
|
||
FetchDescriptor<LocalTemplate>(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<LocalTemplate>(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<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||
sort: \LocalInspection.createdAt
|
||
) private var pendingInspections: [LocalInspection]
|
||
|
||
@Query(
|
||
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
|
||
sort: \LocalIssue.createdAt
|
||
) private var pendingIssues: [LocalIssue]
|
||
|
||
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
|
||
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
|
||
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
|
||
|
||
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)
|
||
|
||
// Retry Failed Items — resets syncStatus back to "pending" so
|
||
// the next triggerSync() will re-attempt them. Once an item
|
||
// reaches syncStatus = "failed" (after 5 consecutive errors)
|
||
// triggerSync() stops picking it up — this is the only way
|
||
// to re-queue it without manual server intervention.
|
||
if hasFailedItems {
|
||
Button {
|
||
retryAllFailed()
|
||
} label: {
|
||
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
|
||
systemImage: "exclamationmark.arrow.circlepath")
|
||
.foregroundStyle(.orange)
|
||
}
|
||
.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")
|
||
}
|
||
|
||
/// Reset all failed items back to pending so the next sync pass picks them up.
|
||
/// Also clears syncRetryCount and syncErrorMessage so the retry counter
|
||
/// starts fresh — prevents them immediately hitting the 5-retry cap again
|
||
/// without any actual new attempt.
|
||
private func retryAllFailed() {
|
||
for insp in failedInspections {
|
||
insp.syncStatus = "pending"
|
||
insp.syncRetryCount = 0
|
||
insp.syncErrorMessage = nil
|
||
}
|
||
for issue in failedIssues {
|
||
issue.syncStatus = "pending"
|
||
issue.syncRetryCount = 0
|
||
issue.syncErrorMessage = nil
|
||
}
|
||
try? context.save()
|
||
Task { await sync.triggerSync() }
|
||
}
|
||
}
|
||
|
||
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]
|
||
|
||
@Environment(\.modelContext) private var context
|
||
@State private var showNewIssue = false
|
||
@State private var searchText = ""
|
||
@State private var severityFilter: String? = nil // nil = all
|
||
|
||
private let severities = ["critical", "high", "medium", "low"]
|
||
|
||
/// Date formatter shared for search matching.
|
||
/// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type
|
||
/// partial strings: "jun", "2026", "19", "4:55" all match.
|
||
private static let searchDateFmt: DateFormatter = {
|
||
let f = DateFormatter()
|
||
f.dateStyle = .medium
|
||
f.timeStyle = .short
|
||
return f
|
||
}()
|
||
|
||
private func dateString(for issue: LocalIssue) -> String {
|
||
let d = issue.serverReportedAt ?? issue.createdAt
|
||
return Self.searchDateFmt.string(from: d)
|
||
}
|
||
|
||
private var issues: [LocalIssue] {
|
||
var list = allIssues.filter { $0.issueStatus != "resolved" }
|
||
|
||
if let sev = severityFilter {
|
||
list = list.filter { $0.severity == sev }
|
||
}
|
||
|
||
if !searchText.isEmpty {
|
||
let q = searchText.lowercased()
|
||
list = list.filter {
|
||
// ID — match "#58" or bare "58"
|
||
let idStr = $0.serverId.map { String($0) } ?? ""
|
||
let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "")
|
||
|
||
return idMatch
|
||
|| $0.issueDescription.lowercased().contains(q)
|
||
|| ($0.facilityNameCache?.lowercased().contains(q) ?? false)
|
||
|| ($0.areaNameCache?.lowercased().contains(q) ?? false)
|
||
|| ($0.assignedToName?.lowercased().contains(q) ?? false)
|
||
|| dateString(for: $0).lowercased().contains(q)
|
||
}
|
||
}
|
||
|
||
return list
|
||
}
|
||
|
||
var body: some View {
|
||
Group {
|
||
if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty {
|
||
ContentUnavailableView(
|
||
"No Issues",
|
||
systemImage: "exclamationmark.triangle",
|
||
description: Text("Tap + to log a new issue, or flag one during an inspection.")
|
||
)
|
||
} else if issues.isEmpty {
|
||
ContentUnavailableView.search(text: searchText)
|
||
} else {
|
||
List(issues) { issue in
|
||
NavigationLink(value: issue) {
|
||
IssueRowView(issue: issue, context: context)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Issues (\(issues.count))")
|
||
.searchable(text: $searchText,
|
||
prompt: "Search ID, date, description, facility…")
|
||
.toolbar {
|
||
ToolbarItemGroup(placement: .primaryAction) {
|
||
// Severity filter
|
||
Menu {
|
||
Button {
|
||
severityFilter = nil
|
||
} label: {
|
||
Label("All Severities",
|
||
systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease")
|
||
}
|
||
Divider()
|
||
ForEach(severities, id: \.self) { sev in
|
||
Button {
|
||
severityFilter = (severityFilter == sev) ? nil : sev
|
||
} label: {
|
||
Label(sev.capitalized,
|
||
systemImage: severityFilter == sev ? "checkmark" : "circle")
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: severityFilter != nil
|
||
? "line.3.horizontal.decrease.circle.fill"
|
||
: "line.3.horizontal.decrease.circle")
|
||
.foregroundStyle(severityFilter != nil ? .orange : .primary)
|
||
}
|
||
|
||
// New Issue — borderedProminent so it stands out clearly
|
||
// from the filter icon and is easy to find at a glance.
|
||
Button {
|
||
showNewIssue = true
|
||
} label: {
|
||
Label("New Issue", systemImage: "plus")
|
||
}
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
}
|
||
.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<LocalFacility>())) ?? []
|
||
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?
|
||
// ── Email PDF ─────────────────────────────────────────────────────────
|
||
@State private var isGeneratingPDF = false
|
||
@State private var showMailCompose = false
|
||
@State private var generatedPDFData: Data? = nil
|
||
|
||
private var facilityName: String {
|
||
let id = issue.facilityServerId
|
||
return (try? context.fetch(
|
||
FetchDescriptor<LocalFacility>(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)
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button {
|
||
Task { await prepareAndShowMail() }
|
||
} label: {
|
||
if isGeneratingPDF {
|
||
ProgressView()
|
||
} else {
|
||
Label("Share via Email", systemImage: "envelope")
|
||
}
|
||
}
|
||
.disabled(!MFMailComposeViewController.canSendMail() || isGeneratingPDF)
|
||
}
|
||
}
|
||
.sheet(isPresented: $showMailCompose) {
|
||
if let pdfData = generatedPDFData {
|
||
MailComposeView(
|
||
subject: "Issue Report — Issue #\(issue.serverId ?? 0) (\(facilityName))",
|
||
body: issueEmailBody,
|
||
pdfData: pdfData,
|
||
pdfFilename: "Issue_\(issue.serverId ?? 0)_\(facilityName).pdf"
|
||
.replacingOccurrences(of: " ", with: "_")
|
||
)
|
||
}
|
||
}
|
||
.task {
|
||
await refreshStatusFromServer()
|
||
await loadComments()
|
||
}
|
||
}
|
||
|
||
/// Generates the issue PDF (re-encoding photos to keep file size minimal,
|
||
/// fetching server photos over the network if synced), then presents the
|
||
/// mail compose sheet with it attached.
|
||
private func prepareAndShowMail() async {
|
||
isGeneratingPDF = true
|
||
let data = await IssuePDFGenerator.generate(issue: issue, facilityName: facilityName)
|
||
generatedPDFData = data
|
||
isGeneratingPDF = false
|
||
showMailCompose = true
|
||
}
|
||
|
||
private var issueEmailBody: String {
|
||
var lines: [String] = []
|
||
lines.append("ISSUE REPORT — Issue #\(issue.serverId ?? 0)")
|
||
lines.append(String(repeating: "=", count: 40))
|
||
lines.append("")
|
||
lines.append("Severity : \(issue.severity.capitalized)")
|
||
lines.append("Status : \(statusLabel(for: issue.issueStatus))")
|
||
lines.append("Facility : \(facilityName)")
|
||
if let area = issue.areaNameCache, !area.isEmpty {
|
||
lines.append("Area : \(area)")
|
||
}
|
||
if let assignee = issue.assignedToName, !assignee.isEmpty {
|
||
lines.append("Assigned : \(assignee)")
|
||
}
|
||
let reportDate = issue.serverReportedAt ?? issue.createdAt
|
||
lines.append("Reported : \(reportDate.formatted(date: .long, time: .shortened))")
|
||
lines.append("")
|
||
lines.append("DESCRIPTION")
|
||
lines.append(String(repeating: "-", count: 40))
|
||
lines.append(issue.issueDescription)
|
||
lines.append("")
|
||
lines.append("— Sent from JanitorialQC Inspector")
|
||
return lines.joined(separator: "\n")
|
||
}
|
||
|
||
// ── 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<Int>()
|
||
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<Int>()
|
||
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<Int>(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<Int>(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.
|
||
// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
@EnvironmentObject private var appearance: AppearanceManager
|
||
@StateObject private var updateChecker = UpdateChecker.shared
|
||
@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
|
||
@State private var hasCheckedOnce = false
|
||
|
||
var body: some View {
|
||
List {
|
||
Section("Account") {
|
||
LabeledContent("Username", value: auth.currentUsername)
|
||
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
||
}
|
||
|
||
Section("Appearance") {
|
||
Picker("Theme", selection: $appearance.mode) {
|
||
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||
Text(mode.displayName).tag(mode)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
}
|
||
|
||
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"))")
|
||
|
||
Button {
|
||
Task {
|
||
await updateChecker.checkForUpdate(force: true)
|
||
hasCheckedOnce = true
|
||
}
|
||
} label: {
|
||
if updateChecker.isChecking {
|
||
HStack {
|
||
ProgressView()
|
||
Text("Checking…")
|
||
}
|
||
} else {
|
||
Label("Check for Updates", systemImage: "arrow.triangle.2.circlepath")
|
||
}
|
||
}
|
||
.disabled(updateChecker.isChecking)
|
||
|
||
if hasCheckedOnce, !updateChecker.isChecking, !updateChecker.updateAvailable {
|
||
Label("You're on the latest version.", systemImage: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
.font(.callout)
|
||
}
|
||
}
|
||
}
|
||
.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<LocalFacility>())) ?? []
|
||
let templates = (try? context.fetch(FetchDescriptor<LocalTemplate>())) ?? []
|
||
let areas = (try? context.fetch(FetchDescriptor<LocalArea>())) ?? []
|
||
|
||
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<LocalIssue>())) ?? []
|
||
allIssues
|
||
.filter { $0.serverId != nil }
|
||
.forEach { context.delete($0) }
|
||
try? context.save()
|
||
}
|
||
}
|