diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index 40de00c..ae6f77e 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -538,1469 +538,6 @@ struct DraftResumeBanner: View { } } -// MARK: - My Inspections - -struct MyInspectionsView: View { - - @Query( - filter: #Predicate { $0.status != "synced" }, - sort: \LocalInspection.lastModifiedAt, - order: .reverse - ) private var inspections: [LocalInspection] - - @Environment(\.modelContext) private var context - - @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( - 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(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Facility" - } - - private var templateName: String { - let id = inspection.templateServerId - return (try? context.fetch( - FetchDescriptor(predicate: #Predicate { $0.serverId == id }) - ).first?.name) ?? "Unknown Template" - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(templateName).font(.headline) - Spacer() - StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) - } - Text(facilityName).font(.callout).foregroundStyle(.secondary) - HStack { - Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2).foregroundStyle(.tertiary) - if let score = inspection.overallScore { - Spacer() - Text(String(format: "%.1f%%", score)) - .font(.caption).fontWeight(.medium) - .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) - } - } - // ── 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(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 { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, - sort: \LocalInspection.createdAt - ) private var pendingInspections: [LocalInspection] - - @Query( - filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, - sort: \LocalIssue.createdAt - ) private var pendingIssues: [LocalIssue] - - 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())) ?? [] - 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(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() - 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() - 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(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(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 @@ -2099,238 +636,3 @@ struct RetryablePhotoView: View { } } -// 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())) ?? [] - let templates = (try? context.fetch(FetchDescriptor())) ?? [] - let areas = (try? context.fetch(FetchDescriptor())) ?? [] - - facilities.forEach { context.delete($0) } - templates.forEach { context.delete($0) } - areas.forEach { context.delete($0) } - - try? context.save() - cacheCleared = true - - // Re-pull immediately if online - if sync.isOnline { - Task { await sync.pullReferenceData() } - } - } - - /// 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())) ?? [] - allIssues - .filter { $0.serverId != nil } - .forEach { context.delete($0) } - try? context.save() - } -} diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 9b5a1f5..f08c7ee 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -65,6 +65,44 @@ struct ExecuteInspectionView: View { private var formSchema: [[String: Any]] { template?.formSchema ?? [] } + /// Live score computed directly from `formValues` (in-memory SwiftUI state) + /// so it updates as the inspector fills in each field — without waiting for + /// `saveDraft()` to flush to SwiftData and `computeScore()` to run. + /// Returns nil when the schema has no scoreable fields. + private var liveScore: Double? { + let scoreable = formSchema.filter { + ["rating", "checkbox", "radio", "pass_fail"].contains($0["type"] as? String ?? "") + } + guard !scoreable.isEmpty else { return nil } + + var total = 0; var earned = 0 + for field in scoreable { + let fid: String + if let s = field["id"] as? String { fid = s } + else if let n = field["id"] as? Int { fid = String(n) } + else { continue } + guard let ftype = field["type"] as? String else { continue } + let val = formValues[fid] ?? "" + + switch ftype { + case "rating": + if let v = Int(val), v > 0 { earned += v; total += field["max"] as? Int ?? 5 } + case "checkbox": + total += 1; if val == "true" { earned += 1 } + case "radio": + total += 1 + if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 } + case "pass_fail": + guard !val.isEmpty else { continue } + total += 1 + if ["pass","yes","ok","good","acceptable","compliant"].contains(val.lowercased()) { earned += 1 } + default: break + } + } + guard total > 0 else { return nil } + return (Double(earned) / Double(total) * 100).rounded(toPlaces: 2) + } + // ── Body ────────────────────────────────────────────────────────────── var body: some View { @@ -83,7 +121,25 @@ struct ExecuteInspectionView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { - ConnectivityBadge() + HStack(spacing: 10) { + // Live score — updates on every field change via formValues binding. + // Only shown when the schema has at least one scoreable field. + if let score = liveScore { + let color: Color = score >= 80 ? .green : score >= 60 ? .orange : .red + HStack(spacing: 4) { + Image(systemName: "chart.bar.fill") + .font(.caption2) + .foregroundStyle(color) + Text(String(format: "%.0f%%", score)) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(color) + } + .padding(.horizontal, 8).padding(.vertical, 4) + .background(color.opacity(0.12)) + .clipShape(Capsule()) + } + ConnectivityBadge() + } } } .onAppear { diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index 10b128c..5b87e2c 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -527,8 +527,15 @@ struct CameraPickerView: UIViewControllerRepresentable { didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] ) { if let img = info[.originalImage] as? UIImage { - parent.image = img - parent.onSelected(img) + // UIImagePickerController with .camera source delivers images whose + // imageOrientation reflects the physical device orientation at capture + // time. On iPad in landscape the raw UIImage is rotated 90° relative + // to what the user sees in the viewfinder. Drawing into a new context + // at the display size bakes the transform into the pixel buffer, + // producing a correctly-oriented image regardless of how it was held. + let normalised = img.normalised() + parent.image = normalised + parent.onSelected(normalised) } picker.dismiss(animated: true) } @@ -715,3 +722,23 @@ struct TableFieldView: View { } } } + +// MARK: - UIImage orientation normalisation +// UIImagePickerController delivers camera photos whose imageOrientation encodes +// the device tilt at capture time. Consumers (savePhotoToDisk, PDF generator, +// issue thumbnails) all call .jpegData() which honours the EXIF orientation — +// but some downstream renderers (UIGraphicsImageRenderer, PDF drawing) ignore it +// and display the raw rotated pixels. This extension bakes the orientation +// transform into the pixel buffer so all consumers see an upright image. + +extension UIImage { + /// Returns a copy of the image with imageOrientation == .up, redrawing + /// the pixels into a new context if the orientation is not already correct. + func normalised() -> UIImage { + guard imageOrientation != .up else { return self } + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + draw(in: CGRect(origin: .zero, size: size)) + } + } +} diff --git a/JanitorialQC/Views/Dashboard/IssuesView.swift b/JanitorialQC/Views/Dashboard/IssuesView.swift new file mode 100644 index 0000000..bcf588d --- /dev/null +++ b/JanitorialQC/Views/Dashboard/IssuesView.swift @@ -0,0 +1,929 @@ +// Views/Dashboard/IssuesView.swift +import SwiftUI +import SwiftData +import MessageUI + +// 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())) ?? [] + 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(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() + 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() + 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(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(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() + } + } +} + diff --git a/JanitorialQC/Views/Dashboard/MyInspectionsView.swift b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift new file mode 100644 index 0000000..29876c2 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/MyInspectionsView.swift @@ -0,0 +1,316 @@ +// Views/Dashboard/MyInspectionsView.swift +import SwiftUI +import SwiftData +import MessageUI + +// MARK: - My Inspections + +struct MyInspectionsView: View { + + @Query( + filter: #Predicate { $0.status != "synced" }, + sort: \LocalInspection.lastModifiedAt, + order: .reverse + ) private var inspections: [LocalInspection] + + @Environment(\.modelContext) private var context + + @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( + 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(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Facility" + } + + private var templateName: String { + let id = inspection.templateServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Template" + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(templateName).font(.headline) + Spacer() + StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus) + } + Text(facilityName).font(.callout).foregroundStyle(.secondary) + HStack { + Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2).foregroundStyle(.tertiary) + if let score = inspection.overallScore { + Spacer() + Text(String(format: "%.1f%%", score)) + .font(.caption).fontWeight(.medium) + .foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red) + } + } + // ── 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(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 + ) + } + } +} + diff --git a/JanitorialQC/Views/Dashboard/NotificationsView.swift b/JanitorialQC/Views/Dashboard/NotificationsView.swift new file mode 100644 index 0000000..7d5cb34 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/NotificationsView.swift @@ -0,0 +1,90 @@ +// Views/Dashboard/NotificationsView.swift +import SwiftUI +import SwiftData +import MessageUI + +// 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 + } + } +} + diff --git a/JanitorialQC/Views/Dashboard/SettingsView.swift b/JanitorialQC/Views/Dashboard/SettingsView.swift new file mode 100644 index 0000000..2d0eb18 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/SettingsView.swift @@ -0,0 +1,240 @@ +// Views/Dashboard/SettingsView.swift +import SwiftUI +import SwiftData +import MessageUI + +// 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())) ?? [] + let templates = (try? context.fetch(FetchDescriptor())) ?? [] + let areas = (try? context.fetch(FetchDescriptor())) ?? [] + + facilities.forEach { context.delete($0) } + templates.forEach { context.delete($0) } + areas.forEach { context.delete($0) } + + try? context.save() + cacheCleared = true + + // Re-pull immediately if online + if sync.isOnline { + Task { await sync.pullReferenceData() } + } + } + + /// 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())) ?? [] + allIssues + .filter { $0.serverId != nil } + .forEach { context.delete($0) } + try? context.save() + } +} diff --git a/JanitorialQC/Views/Dashboard/SyncStatusView.swift b/JanitorialQC/Views/Dashboard/SyncStatusView.swift new file mode 100644 index 0000000..500a228 --- /dev/null +++ b/JanitorialQC/Views/Dashboard/SyncStatusView.swift @@ -0,0 +1,149 @@ +// Views/Dashboard/SyncStatusView.swift +import SwiftUI +import SwiftData +import MessageUI + +// MARK: - Sync Status View + +struct SyncStatusView: View { + @EnvironmentObject private var sync: SyncManager + @Environment(\.modelContext) private var context + + @Query( + filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, + sort: \LocalInspection.createdAt + ) private var pendingInspections: [LocalInspection] + + @Query( + filter: #Predicate { $0.syncStatus == "pending" || $0.syncStatus == "failed" }, + sort: \LocalIssue.createdAt + ) private var pendingIssues: [LocalIssue] + + 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) + } +} +