1077 lines
43 KiB
Swift
1077 lines
43 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
|
|
|
|
// MARK: - SidebarTab
|
|
|
|
enum SidebarTab: Hashable {
|
|
case myInspections
|
|
case issues // inspector role only
|
|
case facilities
|
|
case pendingSync
|
|
case history // moved after Pending Sync
|
|
case settings
|
|
}
|
|
|
|
struct DashboardView: View {
|
|
|
|
@EnvironmentObject private var auth: AuthManager
|
|
@EnvironmentObject private var sync: SyncManager
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var myInspections: [LocalInspection]
|
|
|
|
@State private var selectedTab: SidebarTab = .myInspections
|
|
@State private var showNewInspection = false
|
|
|
|
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
|
/// NavigationStack to be destroyed and recreated — even when the tab
|
|
/// hasn't changed (user is already on it but deep inside a detail view).
|
|
@State private var tabResetId: [SidebarTab: UUID] = [
|
|
.myInspections: UUID(),
|
|
.issues: UUID(),
|
|
.facilities: UUID(),
|
|
.pendingSync: UUID(),
|
|
.history: UUID(),
|
|
.settings: UUID(),
|
|
]
|
|
|
|
/// Explicit paths for the three tabs that push detail views.
|
|
/// Resetting these to empty pops the stack to root immediately and reliably.
|
|
@State private var inspectionsPath = NavigationPath()
|
|
@State private var issuesPath = NavigationPath()
|
|
@State private var historyPath = NavigationPath()
|
|
|
|
/// Tap a sidebar tab: reset all navigable paths, then switch to it.
|
|
private func selectTab(_ tab: SidebarTab) {
|
|
inspectionsPath = NavigationPath()
|
|
issuesPath = NavigationPath()
|
|
historyPath = NavigationPath()
|
|
tabResetId[tab] = UUID()
|
|
selectedTab = tab
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List {
|
|
// ── My Inspections ─────────────────────────────────────────
|
|
Button { selectTab(.myInspections) } label: {
|
|
HStack {
|
|
Label("My Inspections", systemImage: "checklist")
|
|
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
|
|
Spacer()
|
|
if !myInspections.isEmpty {
|
|
Text("\(myInspections.count)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.blue.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
// ── Issues (all roles) ─────────────────────────────────────
|
|
Button { selectTab(.issues) } label: {
|
|
Label("Issues", systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(selectedTab == .issues ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
// ── Facilities ─────────────────────────────────────────────
|
|
Button { selectTab(.facilities) } label: {
|
|
Label("Facilities", systemImage: "building.2")
|
|
.foregroundStyle(selectedTab == .facilities ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
// ── Pending Sync ───────────────────────────────────────────
|
|
Button { selectTab(.pendingSync) } label: {
|
|
HStack {
|
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
|
|
Spacer()
|
|
if sync.pendingCount > 0 {
|
|
Text("\(sync.pendingCount)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.orange.opacity(0.2))
|
|
.foregroundStyle(.orange)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
// ── History (moved — sits between Pending Sync and Settings)
|
|
Button { selectTab(.history) } label: {
|
|
Label("History", systemImage: "clock.arrow.circlepath")
|
|
.foregroundStyle(selectedTab == .history ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
|
|
|
|
// ── Settings ───────────────────────────────────────────────
|
|
Button { selectTab(.settings) } label: {
|
|
Label("Settings", systemImage: "gear")
|
|
.foregroundStyle(selectedTab == .settings ? .blue : .primary)
|
|
}
|
|
.listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear)
|
|
}
|
|
.navigationTitle("JQC Inspector")
|
|
.listStyle(.sidebar)
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button { showNewInspection = true } label: {
|
|
Image(systemName: "plus")
|
|
}
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
|
|
|
} detail: {
|
|
switch selectedTab {
|
|
case .myInspections:
|
|
NavigationStack(path: $inspectionsPath) {
|
|
MyInspectionsView()
|
|
.navigationDestination(for: LocalInspection.self) { inspection in
|
|
if inspection.status == "draft" {
|
|
ExecuteInspectionView(inspection: inspection)
|
|
} else {
|
|
CompletedInspectionView(inspection: inspection)
|
|
}
|
|
}
|
|
}
|
|
case .issues:
|
|
NavigationStack(path: $issuesPath) {
|
|
IssuesListView()
|
|
.navigationDestination(for: LocalIssue.self) { issue in
|
|
IssueDetailView(issue: issue)
|
|
}
|
|
}
|
|
case .facilities:
|
|
NavigationStack { FacilitiesListView() }
|
|
case .pendingSync:
|
|
NavigationStack { SyncStatusView() }
|
|
case .history:
|
|
NavigationStack(path: $historyPath) {
|
|
InspectionHistoryView()
|
|
.navigationDestination(for: APIInspectionSummary.self) { inspection in
|
|
HistoryDetailView(inspection: inspection)
|
|
}
|
|
}
|
|
case .settings:
|
|
NavigationStack { SettingsView() }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showNewInspection) {
|
|
StartInspectionView()
|
|
}
|
|
.task {
|
|
if sync.isOnline {
|
|
await sync.triggerSync()
|
|
} else {
|
|
sync.updatePendingCount(context: context)
|
|
}
|
|
}
|
|
// Schedule background sync when app is backgrounded
|
|
.onChange(of: scenePhase) {
|
|
if scenePhase == .background {
|
|
scheduleBackgroundSync()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var syncStatusFooter: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if sync.isSyncing {
|
|
ProgressView().scaleEffect(0.7)
|
|
} else if let lastSync = sync.lastSyncAt {
|
|
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - My Inspections
|
|
|
|
struct MyInspectionsView: View {
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var inspections: [LocalInspection]
|
|
|
|
@Environment(\.modelContext) private var context
|
|
|
|
// Deletion confirmation state
|
|
@State private var pendingDelete: LocalInspection?
|
|
@State private var showDeleteAlert = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if inspections.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Inspections",
|
|
systemImage: "checklist",
|
|
description: Text("Tap + to start a new inspection.")
|
|
)
|
|
} else {
|
|
List(inspections) { inspection in
|
|
NavigationLink(value: inspection) {
|
|
InspectionRowView(inspection: inspection, context: context)
|
|
}
|
|
// Only drafts may be deleted — submitted/pending-sync inspections are kept
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
if inspection.status == "draft" {
|
|
Button(role: .destructive) {
|
|
pendingDelete = inspection
|
|
showDeleteAlert = true
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("My Inspections")
|
|
// Confirmation before deletion — destructive action cannot be undone
|
|
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
|
Button("Delete", role: .destructive) { deleteDraft(inspection) }
|
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
|
} message: { inspection in
|
|
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
|
}
|
|
}
|
|
|
|
private func draftName(_ inspection: LocalInspection) -> String {
|
|
let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate
|
|
return (try? context.fetch(
|
|
FetchDescriptor<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]
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Status") {
|
|
HStack {
|
|
Circle().fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
}
|
|
if let lastSync = sync.lastSyncAt {
|
|
LabeledContent("Last Sync",
|
|
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
if sync.isSyncing {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Syncing…").foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if let error = sync.syncError {
|
|
Text(error).foregroundStyle(.red).font(.callout)
|
|
}
|
|
Button {
|
|
Task { await sync.triggerSync() }
|
|
} label: {
|
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(!sync.isOnline || sync.isSyncing)
|
|
}
|
|
|
|
if !pendingInspections.isEmpty {
|
|
Section("Pending Inspections (\(pendingInspections.count))") {
|
|
ForEach(pendingInspections) { insp in
|
|
SyncRowView(title: "Inspection", status: insp.syncStatus,
|
|
retryCount: insp.syncRetryCount,
|
|
error: insp.syncErrorMessage, date: insp.createdAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !pendingIssues.isEmpty {
|
|
Section("Pending Issues (\(pendingIssues.count))") {
|
|
ForEach(pendingIssues) { issue in
|
|
SyncRowView(title: "\(issue.severity.capitalized) Issue",
|
|
status: issue.syncStatus, retryCount: issue.syncRetryCount,
|
|
error: issue.syncErrorMessage, date: issue.createdAt)
|
|
}
|
|
}
|
|
}
|
|
|
|
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
|
|
Section {
|
|
Label("All items synced.", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Pending Sync")
|
|
}
|
|
}
|
|
|
|
struct SyncRowView: View {
|
|
let title: String
|
|
let status: String
|
|
let retryCount: Int
|
|
let error: String?
|
|
let date: Date
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(title).font(.callout)
|
|
Spacer()
|
|
Text(status.capitalized).font(.caption2)
|
|
.foregroundStyle(status == "failed" ? .red : .orange)
|
|
}
|
|
Text(date.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
if let err = error {
|
|
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(2)
|
|
}
|
|
if retryCount > 0 {
|
|
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Facilities
|
|
|
|
struct FacilitiesListView: View {
|
|
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
|
|
|
|
var body: some View {
|
|
Group {
|
|
if facilities.isEmpty {
|
|
ContentUnavailableView("No Facilities", systemImage: "building.2.slash",
|
|
description: Text("Connect to the internet to sync your assigned facilities."))
|
|
} else {
|
|
List(facilities) { facility in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(facility.name).font(.headline)
|
|
if !facility.address.isEmpty {
|
|
Text(facility.address).font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
if facility.projectName != "No Contract" {
|
|
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
|
|
}
|
|
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Facilities (\(facilities.count))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Issues (Inspector)
|
|
// Shows issues flagged by this inspector across all inspections.
|
|
// Read-only list — tapping shows description and sync status detail.
|
|
|
|
struct IssuesListView: View {
|
|
|
|
@Query(
|
|
sort: \LocalIssue.createdAt,
|
|
order: .reverse
|
|
) private var issues: [LocalIssue]
|
|
|
|
@Environment(\.modelContext) private var context
|
|
|
|
var body: some View {
|
|
Group {
|
|
if issues.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Issues",
|
|
systemImage: "exclamationmark.triangle",
|
|
description: Text("Issues you flag during inspections will appear here.")
|
|
)
|
|
} else {
|
|
List(issues) { issue in
|
|
NavigationLink(value: issue) {
|
|
IssueRowView(issue: issue, context: context)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Issues (\(issues.count))")
|
|
}
|
|
}
|
|
|
|
struct IssueRowView: View {
|
|
let issue: LocalIssue
|
|
let context: ModelContext
|
|
|
|
private var facilityName: String {
|
|
let id = issue.facilityServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<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
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(severityColor)
|
|
.frame(width: 10, height: 10)
|
|
.padding(.top, 5)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
HStack {
|
|
Text(issue.severity.capitalized)
|
|
.font(.caption.bold())
|
|
.foregroundStyle(severityColor)
|
|
Spacer()
|
|
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
|
|
}
|
|
Text(issue.issueDescription)
|
|
.font(.callout)
|
|
.lineLimit(2)
|
|
Text(facilityName)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct IssueDetailView: View {
|
|
let issue: LocalIssue
|
|
@Environment(\.modelContext) private var context
|
|
@EnvironmentObject private var sync: SyncManager
|
|
|
|
@State private var isLoadingStatus = false
|
|
@State private var isUpdatingStatus = false
|
|
@State private var statusError: String?
|
|
@State private var showStatusPicker = false
|
|
|
|
private var facilityName: String {
|
|
let id = issue.facilityServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<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)
|
|
LabeledContent("Reported", value: issue.createdAt.formatted(
|
|
date: .long, time: .shortened))
|
|
|
|
// ── Issue Status ───────────────────────────────────────────
|
|
LabeledContent("Issue Status") {
|
|
HStack(spacing: 6) {
|
|
if isLoadingStatus {
|
|
ProgressView().scaleEffect(0.7)
|
|
} else {
|
|
Text(statusLabel(for: issue.issueStatus))
|
|
.foregroundStyle(statusColor(for: issue.issueStatus))
|
|
.fontWeight(.semibold)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Status picker (online + synced only) ───────────────────
|
|
if canUpdateStatus {
|
|
if isUpdatingStatus {
|
|
HStack {
|
|
ProgressView()
|
|
Text("Updating…").foregroundStyle(.secondary).font(.callout)
|
|
}
|
|
} else {
|
|
Picker("Change Status", selection: Binding(
|
|
get: { issue.issueStatus },
|
|
set: { newStatus in
|
|
Task { await changeStatus(to: newStatus) }
|
|
}
|
|
)) {
|
|
ForEach(allStatuses, id: \.value) { s in
|
|
Text(s.label).tag(s.value)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.tint(statusColor(for: issue.issueStatus))
|
|
}
|
|
}
|
|
|
|
if let err = statusError {
|
|
Text(err).font(.caption).foregroundStyle(.red)
|
|
}
|
|
}
|
|
|
|
Section("Description") {
|
|
Text(issue.issueDescription)
|
|
.font(.callout)
|
|
}
|
|
|
|
Section("Sync Status") {
|
|
LabeledContent("Sync") {
|
|
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
|
|
}
|
|
if let err = issue.syncErrorMessage {
|
|
Text(err).font(.caption).foregroundStyle(.red)
|
|
}
|
|
if issue.syncRetryCount > 0 {
|
|
LabeledContent("Retry Count", value: "\(issue.syncRetryCount)")
|
|
}
|
|
}
|
|
|
|
if !issue.photoLocalPaths.isEmpty {
|
|
Section("Photos (\(issue.photoLocalPaths.count))") {
|
|
ForEach(issue.photoLocalPaths, id: \.self) { path in
|
|
if let img = UIImage(contentsOfFile: path) {
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} else {
|
|
Label("Photo pending upload", systemImage: "photo")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !issue.photoServerPaths.isEmpty {
|
|
Section("Photos (\(issue.photoServerPaths.count))") {
|
|
ForEach(issue.photoServerPaths, id: \.self) { relativePath in
|
|
AsyncImage(url: URL(string: Constants.baseURL + "/" + relativePath)) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image
|
|
.resizable()
|
|
.scaledToFit()
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
case .failure:
|
|
Label("Photo unavailable", systemImage: "photo.slash")
|
|
.foregroundStyle(.secondary)
|
|
case .empty:
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
@unknown default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Issue Detail")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task {
|
|
await refreshStatusFromServer()
|
|
}
|
|
}
|
|
|
|
// ── Fetch fresh status from server ────────────────────────────────────
|
|
|
|
private func refreshStatusFromServer() async {
|
|
guard sync.isOnline, let sid = issue.serverId else { return }
|
|
isLoadingStatus = true
|
|
statusError = nil
|
|
defer { isLoadingStatus = false }
|
|
do {
|
|
let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid)
|
|
issue.issueStatus = detail.status
|
|
try? context.save()
|
|
} catch {
|
|
// Non-fatal — show cached status silently
|
|
}
|
|
}
|
|
|
|
// ── Push status change to server ──────────────────────────────────────
|
|
|
|
private func changeStatus(to newStatus: String) async {
|
|
guard let sid = issue.serverId else { return }
|
|
isUpdatingStatus = true
|
|
statusError = nil
|
|
defer { isUpdatingStatus = false }
|
|
do {
|
|
let confirmed = try await APIClient.shared.updateIssueStatus(
|
|
issueId: sid, status: newStatus
|
|
)
|
|
issue.issueStatus = confirmed
|
|
try? context.save()
|
|
} catch {
|
|
statusError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Templates
|
|
|
|
struct TemplatesListView: View {
|
|
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
|
|
|
|
var body: some View {
|
|
Group {
|
|
if templates.isEmpty {
|
|
ContentUnavailableView("No Templates", systemImage: "doc.text.magnifyingglass",
|
|
description: Text("Connect to the internet to sync inspection templates."))
|
|
} else {
|
|
List(templates) { template in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(template.name).font(.headline)
|
|
if !template.templateDescription.isEmpty {
|
|
Text(template.templateDescription)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
|
}
|
|
HStack {
|
|
if !template.frequency.isEmpty {
|
|
Label(template.frequencyLabel, systemImage: "clock")
|
|
.font(.caption2).foregroundStyle(.blue)
|
|
}
|
|
Spacer()
|
|
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
|
|
.font(.caption2).foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Templates (\(templates.count))")
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings
|
|
|
|
struct SettingsView: View {
|
|
@EnvironmentObject private var auth: AuthManager
|
|
@EnvironmentObject private var sync: SyncManager
|
|
@Environment(\.modelContext) private var context
|
|
|
|
@State private var showClearCacheAlert = false
|
|
@State private var cacheCleared = false
|
|
|
|
var body: some View {
|
|
List {
|
|
Section("Account") {
|
|
LabeledContent("Username", value: auth.currentUsername)
|
|
LabeledContent("Role", value: auth.currentUserRole.capitalized)
|
|
}
|
|
|
|
Section("Sync") {
|
|
Button {
|
|
Task { await sync.triggerSync() }
|
|
} label: {
|
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(!sync.isOnline || sync.isSyncing)
|
|
|
|
if let error = sync.syncError {
|
|
Text(error).font(.caption).foregroundStyle(.red)
|
|
}
|
|
|
|
if let lastSync = sync.lastSyncAt {
|
|
LabeledContent("Last Sync",
|
|
value: lastSync.formatted(date: .abbreviated, time: .shortened))
|
|
}
|
|
}
|
|
|
|
Section("Cache") {
|
|
Button {
|
|
showClearCacheAlert = true
|
|
} label: {
|
|
Label("Clear Reference Cache", systemImage: "trash")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Text("Clears locally cached facilities, areas, and templates. Your pending inspections are not affected. Data will re-sync on the next connection.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if cacheCleared {
|
|
Label("Cache cleared.", systemImage: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
.font(.callout)
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Button(role: .destructive) {
|
|
Task { await auth.logout() }
|
|
} label: {
|
|
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
}
|
|
|
|
Section("App Info") {
|
|
LabeledContent("Version", value: "Phase C")
|
|
LabeledContent("Server", value: Constants.baseURL)
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.alert("Clear Reference Cache?", isPresented: $showClearCacheAlert) {
|
|
Button("Clear", role: .destructive) { clearCache() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Facilities, areas, and templates will be removed from local storage and re-downloaded on the next sync. Pending inspections are not affected.")
|
|
}
|
|
}
|
|
|
|
private func clearCache() {
|
|
// Delete only reference data — never touch LocalInspection, LocalIssue, PendingPhoto
|
|
let facilities = (try? context.fetch(FetchDescriptor<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() }
|
|
}
|
|
}
|
|
}
|