From 9d6b5e5bcb21c21b3d69fdfac3e2fef45974cebe Mon Sep 17 00:00:00 2001 From: Nguyen Ngo Date: Mon, 4 May 2026 17:36:55 -0400 Subject: [PATCH] 05/04 Update the app functionalities --- JanitorialQC/API/APIClient.swift | 18 +- JanitorialQC/Models/LocalIssue.swift | 6 +- .../Views/Dashboard/DashboardView.swift | 296 +++++++++++-- .../Dashboard/ExecuteInspectionView.swift | 210 +++++++-- .../Views/Dashboard/FlagIssueView.swift | 84 ++-- .../Views/Dashboard/FormFieldView.swift | 85 +++- .../Inspection/InspectionHistoryView.swift | 412 +++++++++++++++++- 7 files changed, 980 insertions(+), 131 deletions(-) diff --git a/JanitorialQC/API/APIClient.swift b/JanitorialQC/API/APIClient.swift index d2ef349..b4c0851 100644 --- a/JanitorialQC/API/APIClient.swift +++ b/JanitorialQC/API/APIClient.swift @@ -37,10 +37,12 @@ private struct _Envelope: Decodable, Sendable { private enum CodingKeys: String, CodingKey { case ok, data, error } } -// Refresh-only envelope — uses a non-Sendable-constrained local struct -// decoded manually to avoid pulling RefreshResponseData into the Sendable chain. -private struct _RefreshEnvelope: Decodable { - struct Tokens: Decodable { +// Free function removed — see refreshAccessToken() which decodes using a +// local JSONDecoder to avoid Swift 6 actor-isolation errors. + +// Refresh-only envelope — Sendable so it can cross actor boundaries in Swift 6. +private struct _RefreshEnvelope: Decodable, Sendable { + struct Tokens: Decodable, Sendable { let accessToken: String let refreshToken: String } @@ -170,7 +172,7 @@ actor APIClient { func submitIssue(_ issue: LocalIssue) async throws -> Int { var body: [String: Any] = [ - "area_id": issue.areaServerId, + "facility_id": issue.facilityServerId, "severity": issue.severity, "description": issue.issueDescription, "mobile_local_id": issue.localId, @@ -199,7 +201,11 @@ actor APIClient { let http = response as? HTTPURLResponse, http.statusCode == 200 else { return false } - guard let env = try? decoder.decode(_RefreshEnvelope.self, from: data), + // Use a local decoder — avoids referencing the actor-isolated self.decoder + // which would trigger a Swift 6 main-actor isolation error. + let localDecoder = JSONDecoder() + localDecoder.keyDecodingStrategy = .convertFromSnakeCase + guard let env = try? localDecoder.decode(_RefreshEnvelope.self, from: data), env.ok, let tokens = env.data else { return false } diff --git a/JanitorialQC/Models/LocalIssue.swift b/JanitorialQC/Models/LocalIssue.swift index 4fda946..a4c1f5c 100644 --- a/JanitorialQC/Models/LocalIssue.swift +++ b/JanitorialQC/Models/LocalIssue.swift @@ -12,7 +12,7 @@ final class LocalIssue { var serverId: Int? var inspectionLocalId: String // references LocalInspection.localId - var areaServerId: Int + var facilityServerId: Int // facility this issue belongs to (replaces areaServerId) var severity: String // "low" | "medium" | "high" | "critical" var issueDescription: String var photoLocalPath: String? // local file path before upload @@ -27,14 +27,14 @@ final class LocalIssue { init( inspectionLocalId: String, - areaServerId: Int, + facilityServerId: Int, severity: String, description: String ) { self.localId = UUID().uuidString self.serverId = nil self.inspectionLocalId = inspectionLocalId - self.areaServerId = areaServerId + self.facilityServerId = facilityServerId self.severity = severity self.issueDescription = description self.photoLocalPath = nil diff --git a/JanitorialQC/Views/Dashboard/DashboardView.swift b/JanitorialQC/Views/Dashboard/DashboardView.swift index ba1cd46..9cc1327 100644 --- a/JanitorialQC/Views/Dashboard/DashboardView.swift +++ b/JanitorialQC/Views/Dashboard/DashboardView.swift @@ -2,11 +2,29 @@ // ------------------------------------ // Phase C: adds Inspection History tab, polished Settings with cache clear, // and schedules background sync on scene enter background. +// +// CHANGED (sidebar update): +// - Templates removed from sidebar entirely. +// - Issues view added for all roles. +// - History moved to sit between Pending Sync and Settings. +// - Tab identity is now enum-based (SidebarTab) instead of raw Int +// so adding/removing tabs never breaks the detail switch. import SwiftUI import SwiftData import Combine +// MARK: - SidebarTab + +enum SidebarTab: Hashable { + case myInspections + case issues // inspector role only + case facilities + case pendingSync + case history // moved after Pending Sync + case settings +} + struct DashboardView: View { @EnvironmentObject private var auth: AuthManager @@ -20,17 +38,17 @@ struct DashboardView: View { order: .reverse ) private var myInspections: [LocalInspection] - @State private var selectedTab = 0 + @State private var selectedTab: SidebarTab = .myInspections @State private var showNewInspection = false var body: some View { NavigationSplitView { List { - // My Inspections - Button { selectedTab = 0 } label: { + // ── My Inspections ───────────────────────────────────────── + Button { selectedTab = .myInspections } label: { HStack { Label("My Inspections", systemImage: "checklist") - .foregroundStyle(selectedTab == 0 ? .blue : .primary) + .foregroundStyle(selectedTab == .myInspections ? .blue : .primary) Spacer() if !myInspections.isEmpty { Text("\(myInspections.count)") @@ -41,34 +59,27 @@ struct DashboardView: View { } } } - .listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear) + .listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear) - // History - Button { selectedTab = 1 } label: { - Label("History", systemImage: "clock.arrow.circlepath") - .foregroundStyle(selectedTab == 1 ? .blue : .primary) + // ── Issues (all roles) ───────────────────────────────────── + Button { selectedTab = .issues } label: { + Label("Issues", systemImage: "exclamationmark.triangle") + .foregroundStyle(selectedTab == .issues ? .blue : .primary) } - .listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear) + .listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear) - // Facilities - Button { selectedTab = 2 } label: { + // ── Facilities ───────────────────────────────────────────── + Button { selectedTab = .facilities } label: { Label("Facilities", systemImage: "building.2") - .foregroundStyle(selectedTab == 2 ? .blue : .primary) + .foregroundStyle(selectedTab == .facilities ? .blue : .primary) } - .listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear) + .listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear) - // Templates - Button { selectedTab = 3 } label: { - Label("Templates", systemImage: "doc.text") - .foregroundStyle(selectedTab == 3 ? .blue : .primary) - } - .listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear) - - // Pending Sync - Button { selectedTab = 4 } label: { + // ── Pending Sync ─────────────────────────────────────────── + Button { selectedTab = .pendingSync } label: { HStack { Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(selectedTab == 4 ? .blue : .primary) + .foregroundStyle(selectedTab == .pendingSync ? .blue : .primary) Spacer() if sync.pendingCount > 0 { Text("\(sync.pendingCount)") @@ -80,14 +91,21 @@ struct DashboardView: View { } } } - .listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear) + .listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear) - // Settings - Button { selectedTab = 5 } label: { - Label("Settings", systemImage: "gear") - .foregroundStyle(selectedTab == 5 ? .blue : .primary) + // ── History (moved — sits between Pending Sync and Settings) + Button { selectedTab = .history } label: { + Label("History", systemImage: "clock.arrow.circlepath") + .foregroundStyle(selectedTab == .history ? .blue : .primary) } - .listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear) + .listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear) + + // ── Settings ─────────────────────────────────────────────── + Button { selectedTab = .settings } label: { + Label("Settings", systemImage: "gear") + .foregroundStyle(selectedTab == .settings ? .blue : .primary) + } + .listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear) } .navigationTitle("JQC Inspector") .listStyle(.sidebar) @@ -101,17 +119,13 @@ struct DashboardView: View { .safeAreaInset(edge: .bottom) { syncStatusFooter } } detail: { - // CHANGED: each tab is wrapped in its own NavigationStack. - // Without this, NavigationLink pushes from MyInspectionsView accumulate - // on a shared implicit stack — switching sidebar tabs does not clear - // the pushed ExecuteInspectionView, leaving the form stuck on screen. switch selectedTab { - case 0: NavigationStack { MyInspectionsView() } - case 1: NavigationStack { InspectionHistoryView() } - case 2: NavigationStack { FacilitiesListView() } - case 3: NavigationStack { TemplatesListView() } - case 4: NavigationStack { SyncStatusView() } - default: NavigationStack { SettingsView() } + case .myInspections: NavigationStack { MyInspectionsView() } + case .issues: NavigationStack { IssuesListView() } + case .facilities: NavigationStack { FacilitiesListView() } + case .pendingSync: NavigationStack { SyncStatusView() } + case .history: NavigationStack { InspectionHistoryView() } + case .settings: NavigationStack { SettingsView() } } } .sheet(isPresented: $showNewInspection) { @@ -169,6 +183,10 @@ struct MyInspectionsView: View { @Environment(\.modelContext) private var context + // Deletion confirmation state + @State private var pendingDelete: LocalInspection? + @State private var showDeleteAlert = false + var body: some View { Group { if inspections.isEmpty { @@ -188,10 +206,55 @@ struct MyInspectionsView: View { } label: { InspectionRowView(inspection: inspection, context: context) } + // Only drafts may be deleted — submitted/pending-sync inspections are kept + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if inspection.status == "draft" { + Button(role: .destructive) { + pendingDelete = inspection + showDeleteAlert = true + } label: { + Label("Delete", systemImage: "trash") + } + } + } } } } .navigationTitle("My Inspections") + // Confirmation before deletion — destructive action cannot be undone + .alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in + Button("Delete", role: .destructive) { deleteDraft(inspection) } + Button("Cancel", role: .cancel) { pendingDelete = nil } + } message: { inspection in + Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.") + } + } + + private func draftName(_ inspection: LocalInspection) -> String { + let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate + return (try? context.fetch( + FetchDescriptor( + 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 { + if let path = issue.photoLocalPath { + try? FileManager.default.removeItem(atPath: path) + } + context.delete(issue) + } + context.delete(inspection) + try? context.save() + pendingDelete = nil } } @@ -480,6 +543,159 @@ struct FacilitiesListView: View { } } +// MARK: - Issues (Inspector) +// Shows issues flagged by this inspector across all inspections. +// Read-only list — tapping shows description and sync status detail. + +struct IssuesListView: View { + + @Query( + sort: \LocalIssue.createdAt, + order: .reverse + ) private var issues: [LocalIssue] + + @Environment(\.modelContext) private var context + + var body: some View { + Group { + if issues.isEmpty { + ContentUnavailableView( + "No Issues", + systemImage: "exclamationmark.triangle", + description: Text("Issues you flag during inspections will appear here.") + ) + } else { + List(issues) { issue in + NavigationLink { + IssueDetailView(issue: issue) + } label: { + IssueRowView(issue: issue, context: context) + } + } + } + } + .navigationTitle("Issues (\(issues.count))") + } +} + +struct IssueRowView: View { + let issue: LocalIssue + let context: ModelContext + + private var facilityName: String { + let id = issue.facilityServerId + return (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first?.name) ?? "Unknown Facility" + } + + private var severityColor: Color { + switch issue.severity { + case "critical": return .red + case "high": return .orange + case "medium": return .yellow + default: return .blue + } + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Circle() + .fill(severityColor) + .frame(width: 10, height: 10) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(issue.severity.capitalized) + .font(.caption.bold()) + .foregroundStyle(severityColor) + Spacer() + StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) + } + Text(issue.issueDescription) + .font(.callout) + .lineLimit(2) + Text(facilityName) + .font(.caption) + .foregroundStyle(.secondary) + Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 4) + } +} + +struct IssueDetailView: View { + let issue: LocalIssue + @Environment(\.modelContext) private var context + + 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 + } + } + + var body: some View { + List { + Section("Issue Details") { + LabeledContent("Severity") { + Text(issue.severity.capitalized) + .foregroundStyle(severityColor) + .fontWeight(.semibold) + } + LabeledContent("Facility", value: facilityName) + LabeledContent("Reported", value: issue.createdAt.formatted( + date: .long, time: .shortened)) + } + + Section("Description") { + Text(issue.issueDescription) + .font(.callout) + } + + Section("Sync Status") { + LabeledContent("Status") { + StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus) + } + if let err = issue.syncErrorMessage { + Text(err).font(.caption).foregroundStyle(.red) + } + if issue.syncRetryCount > 0 { + LabeledContent("Retry Count", value: "\(issue.syncRetryCount)") + } + } + + if let photoPath = issue.photoLocalPath { + Section("Photo") { + if let img = UIImage(contentsOfFile: photoPath) { + Image(uiImage: img) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Label("Photo pending upload", systemImage: "photo") + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Issue Detail") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - Templates struct TemplatesListView: View { diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 186e109..6ef421d 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -67,7 +67,7 @@ struct ExecuteInspectionView: View { .padding(.horizontal, 24) .padding(.vertical, 16) } - .background(Color(.systemGroupedBackground)) + .background(Color(.systemBackground)) .navigationTitle(template?.name ?? "Inspection") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -140,10 +140,6 @@ struct ExecuteInspectionView: View { .padding(.bottom, 16) } - // Inspector notes card - notesCard - .padding(.bottom, 16) - // Action buttons actionButtons .padding(.bottom, 32) @@ -395,10 +391,21 @@ struct ExecuteInspectionView: View { } } +// MARK: - WidthPreferenceKey +// Used by GridFormView to read its container width reliably on any device +// orientation, split-screen size change, or rotation — without GeometryReader's +// ScrollView height ambiguity. + +private struct WidthPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + // MARK: - GridFormView -// CHANGED: new view — renders the form schema using the same 12-column grid -// layout as the web app's .form-grid CSS grid. Each field is positioned using -// its col/row/colSpan/rowSpan attributes from the JSON schema. +// Renders the form schema using the same 12-column grid as the web app's +// .form-grid CSS grid. Each field is positioned using col/row/colSpan/rowSpan. struct GridFormView: View { @@ -407,38 +414,58 @@ struct GridFormView: View { var onPhotoSelected: ((String, [String: Any]) -> Void)? var onFieldChanged: (() -> Void)? - // Grid constants — match the web app - static let totalColumns: Int = 12 - static let cellGap: CGFloat = 4 // column gap (web: 4px) - static let rowGap: CGFloat = 4 // row gap (web: 4px) - static let cellAspect: CGFloat = 52/72 // cellH / cellW (web: 52px / 72px) - static let cardPadding: CGFloat = 16 // card inset on all sides + // ── Grid constants — kept in sync with the web form editor ────────────── + // Web editor JS: COLS=12 CELL_W=72 CELL_H=52 GAP=8 (col gap = 8px) + // Web CSS execute: gap: 4px 8px (row-gap=4px, col-gap=8px) + static let totalColumns: Int = 12 + static let cellGap: CGFloat = 8 // column gap — matches editor GAP=8 and CSS col-gap + static let rowGap: CGFloat = 4 // row gap — matches CSS row-gap + static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W + static let cardPadding: CGFloat = 16 // card inset on all sides - // @State to capture the rendered grid width from the background GeometryReader. - // Starts at a reasonable iPad default (952 = 1000 max-width − 2×24 outer padding). - @State private var gridWidth: CGFloat = 952 + // Minimum cell height (points) per field type — ensures 44pt touch targets + // on iPad even when the template author assigned a very short rowSpan. + static let minCellH: [String: CGFloat] = [ + "pass_fail": 44, + "rating": 36, + "checkbox": 36, + "checkbox_group": 44, + "radio": 44, + "select": 36, + "date": 36, + "image": 60, + "signature": 80, + "table": 80, + ] + + // Width captured via PreferenceKey — updates on rotation & split-screen. + // Default 952 = 1000 max-width − 2×24 outer padding (safe iPad landscape floor). + @State private var containerWidth: CGFloat = 952 var body: some View { - // Use a zero-height background reader so the ScrollView sees the correct - // intrinsic height of the ZStack, not GeometryReader's proposed size. ZStack(alignment: .topLeading) { - // Card background + // ── Card background ──────────────────────────────────────────── RoundedRectangle(cornerRadius: 12) - .fill(Color(.secondarySystemGroupedBackground)) + .fill(Color(.secondarySystemBackground)) - // Width probe — invisible, sits behind the grid, reads available width + // ── Width probe — zero-size overlay, reports container width ─── + // Using a background Color.clear with a GeometryReader that sends + // its width via PreferenceKey is the idiomatic SwiftUI pattern that + // works correctly inside ScrollView on all iOS versions. Color.clear - .frame(height: 1) + .frame(maxWidth: .infinity) + .frame(height: 0) .background( GeometryReader { geo in - Color.clear.onAppear { gridWidth = max(geo.size.width, 100) } + Color.clear.preference( + key: WidthPreferenceKey.self, + value: geo.size.width + ) } ) - // Field overlays — positioned using live gridWidth - let cellW = (gridWidth - 2 * Self.cardPadding - - CGFloat(Self.totalColumns - 1) * Self.cellGap) - / CGFloat(Self.totalColumns) + // ── Field overlays ───────────────────────────────────────────── + let cellW = computedCellW let cellH = cellW * Self.cellAspect ForEach(schema.indices, id: \.self) { idx in @@ -449,17 +476,26 @@ struct GridFormView: View { } } } - // Explicit height derived from the same cellW/cellH arithmetic — - // this is what the ScrollView measures, so it can never be wrong. + .onPreferenceChange(WidthPreferenceKey.self) { width in + if width > 0 { containerWidth = width } + } + // Height is always derived from the same arithmetic as cell offsets — + // the ScrollView measures this frame and can never be wrong. .frame(height: canvasHeight() + 2 * Self.cardPadding) } + // ── Derived cell width from current containerWidth ──────────────────── + + private var computedCellW: CGFloat { + (containerWidth - 2 * Self.cardPadding + - CGFloat(Self.totalColumns - 1) * Self.cellGap) + / CGFloat(Self.totalColumns) + } + // ── Canvas height ───────────────────────────────────────────────────── private func canvasHeight() -> CGFloat { - let cellW = (gridWidth - 2 * Self.cardPadding - - CGFloat(Self.totalColumns - 1) * Self.cellGap) - / CGFloat(Self.totalColumns) + let cellW = computedCellW let cellH = cellW * Self.cellAspect let maxRow = schema.reduce(0) { acc, f in let r = f["row"] as? Int ?? 1 @@ -482,7 +518,12 @@ struct GridFormView: View { let yOffset = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap - let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap + + // Apply per-type minimum height so touch targets are always reachable. + let ftype = field["type"] as? String ?? "text" + let rawHeight = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap + let minH = Self.minCellH[ftype] ?? 0 + let height = max(rawHeight, minH) let fid = fieldId(field) @@ -544,9 +585,11 @@ struct GridCellContentView: View { } } - // ── Input widget fills remaining cell height ── + // ── Input widget — sized naturally, not stretched to fill cell ── + // maxHeight:.infinity caused a large gap between the label and the + // input widget when the cell was taller than the content needed. fieldInput - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) // ── Help text — matches .help-text ── if !helpText.isEmpty { @@ -556,7 +599,9 @@ struct GridCellContentView: View { .lineLimit(2) } } - .clipped() + // No .clipped() — overflow is intentionally visible so tall content + // (dropdowns, multi-line labels) is never silently truncated. + // Matches the web form's .fg-cell { overflow: visible } rule. } @ViewBuilder @@ -661,8 +706,9 @@ struct GridCellContentView: View { ) // ── Image / Photo upload ────────────────────────────────────────── + // Uses a compact inline zone to match the web's .upload-zone dashed style. case "image": - ImageFieldView( + CompactImageFieldView( fieldId: field["id"] as? String ?? UUID().uuidString, currentValue: value, onPhotoSelected: onPhotoSelected @@ -698,6 +744,94 @@ struct GridCellContentView: View { } } +// MARK: - CompactImageFieldView +// Grid-cell-sized photo upload zone — mirrors the web's .upload-zone style: +// dashed border, small icon + text, filename shown inline when a photo is chosen. +// CHANGED: replaces the full-size ImageFieldView inside grid cells to fix the +// oversized "Attach Photo" button that was too large for compact grid cells. + +struct CompactImageFieldView: View { + let fieldId: String + let currentValue: String + var onPhotoSelected: ((String) -> Void)? + + @State private var selectedImage: UIImage? + @State private var chosenName: String = "" + @State private var showChoice = false + @State private var showCamera = false + @State private var showLibrary = false + + private var cameraAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } + + var hasPhoto: Bool { selectedImage != nil || currentValue.hasPrefix("uploads/") || currentValue.hasPrefix("local://") } + + var body: some View { + Button { + if cameraAvailable { showChoice = true } else { showLibrary = true } + } label: { + HStack(spacing: 6) { + Image(systemName: hasPhoto ? "photo.fill" : "camera") + .font(.system(size: 13)) + .foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel)) + VStack(alignment: .leading, spacing: 1) { + Text(hasPhoto ? (chosenName.isEmpty ? "Photo attached" : chosenName) + : "Upload photo") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel)) + .lineLimit(1) + .truncationMode(.middle) + if !hasPhoto { + Text("Tap to choose") + .font(.system(size: 10)) + .foregroundStyle(Color(.tertiaryLabel)) + } + } + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, minHeight: 44) + .background(hasPhoto ? Color.blue.opacity(0.07) : Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + hasPhoto ? Color.blue.opacity(0.4) : Color(.systemGray4), + style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]) + ) + ) + } + .buttonStyle(.plain) + .confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) { + Button("Take Photo") { showCamera = true } + Button("Photo Library") { showLibrary = true } + Button("Cancel", role: .cancel) {} + } + .fullScreenCover(isPresented: $showCamera) { + CameraPickerView(image: $selectedImage, onSelected: saveAndCallback) + .ignoresSafeArea() + } + .sheet(isPresented: $showLibrary) { + LibraryPickerView(image: $selectedImage, onSelected: saveAndCallback) + } + } + + private func saveAndCallback(_ img: UIImage) { + guard let data = img.jpegData(compressionQuality: 0.8) else { return } + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let photosDir = docs.appendingPathComponent("JQC/Photos", isDirectory: true) + try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true) + let filename = "\(UUID().uuidString).jpg" + let url = photosDir.appendingPathComponent(filename) + try? data.write(to: url) + selectedImage = img + chosenName = filename + onPhotoSelected?(url.path) + } +} + // MARK: - CellDatePicker // Compact date picker for a grid cell — shows a short date format. diff --git a/JanitorialQC/Views/Dashboard/FlagIssueView.swift b/JanitorialQC/Views/Dashboard/FlagIssueView.swift index 7dc0ef0..d9b3221 100644 --- a/JanitorialQC/Views/Dashboard/FlagIssueView.swift +++ b/JanitorialQC/Views/Dashboard/FlagIssueView.swift @@ -1,7 +1,12 @@ -// Views/Inspection/FlagIssueView.swift -// ------------------------------------- +// Views/Dashboard/FlagIssueView.swift +// ------------------------------------ // Sheet for flagging an issue during an inspection. // Saves locally immediately; syncs to server when online. +// +// CHANGED: Area picker removed. Facility is derived directly from the +// inspection (inspection.facilityServerId) and displayed as read-only info, +// matching the web app's flag_issue.html behaviour where facility_id is +// a hidden field populated from the inspection context. import SwiftUI import SwiftData @@ -14,42 +19,55 @@ struct FlagIssueView: View { let inspection: LocalInspection - @State private var selectedAreaId: Int? - @State private var severity = "medium" - @State private var description = "" + @State private var severity = "medium" + @State private var description = "" @State private var selectedImage: UIImage? @State private var photoLocalPath: String? - @State private var showImagePicker = false + @State private var showChoice = false + @State private var showCamera = false + @State private var showLibrary = false + + private var cameraAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } private let severities = ["low", "medium", "high", "critical"] - private var areas: [LocalArea] { - let facilityId = inspection.facilityServerId - let results = try? context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.facilityServerId == facilityId }, - sortBy: [SortDescriptor(\.name)] + private var facility: LocalFacility? { + let id = inspection.facilityServerId + return try? context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.serverId == id } ) - ) - return results ?? [] + ).first } private var canSubmit: Bool { - selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty + !description.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { NavigationStack { Form { - // ── Area ─────────────────────────────────────────────────── - Section("Area") { - Picker("Area", selection: $selectedAreaId) { - Text("Select area…").tag(Optional(nil)) - ForEach(areas) { area in - Text(area.name).tag(Optional(area.serverId)) + // ── Facility (read-only) — matches web alert banner ──────── + Section { + HStack(spacing: 10) { + Image(systemName: "building.2") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Facility") + .font(.caption) + .foregroundStyle(.secondary) + Text(facility?.name ?? "—") + .font(.body) } } - .pickerStyle(.navigationLink) + .padding(.vertical, 2) + } header: { + Text("Inspection Context") + } footer: { + Text("Issue will be logged against this facility.") + .font(.caption) } // ── Severity ─────────────────────────────────────────────── @@ -78,7 +96,7 @@ struct FlagIssueView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } Button { - showImagePicker = true + if cameraAvailable { showChoice = true } else { showLibrary = true } } label: { Label(selectedImage == nil ? "Attach Photo" : "Replace Photo", systemImage: "camera") @@ -107,10 +125,17 @@ struct FlagIssueView: View { .fontWeight(.semibold) } } - .sheet(isPresented: $showImagePicker) { - ImagePickerView(image: $selectedImage) { img in - savePhoto(img) - } + .confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) { + Button("Take Photo") { showCamera = true } + Button("Photo Library") { showLibrary = true } + Button("Cancel", role: .cancel) {} + } + .fullScreenCover(isPresented: $showCamera) { + CameraPickerView(image: $selectedImage, onSelected: savePhoto) + .ignoresSafeArea() + } + .sheet(isPresented: $showLibrary) { + LibraryPickerView(image: $selectedImage, onSelected: savePhoto) } } } @@ -129,11 +154,9 @@ struct FlagIssueView: View { } private func submitIssue() { - guard let areaId = selectedAreaId else { return } - let issue = LocalIssue( inspectionLocalId: inspection.localId, - areaServerId: areaId, + facilityServerId: inspection.facilityServerId, severity: severity, description: description.trimmingCharacters(in: .whitespaces) ) @@ -142,7 +165,6 @@ struct FlagIssueView: View { inspection.localIssues.append(issue) context.insert(issue) - // Create PendingPhoto if a photo was attached if let path = photoLocalPath { let photo = PendingPhoto( localFilePath: path, diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index dedb781..f029dfa 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -12,6 +12,7 @@ import SwiftUI import PencilKit +import PhotosUI // MARK: - FormFieldView // Retained for standalone/legacy usage outside the grid inspection form. @@ -420,8 +421,14 @@ struct ImageFieldView: View { let currentValue: String var onPhotoSelected: ((String) -> Void)? - @State private var showPicker = false @State private var selectedImage: UIImage? + @State private var showChoice = false + @State private var showCamera = false + @State private var showLibrary = false + + private var cameraAvailable: Bool { + UIImagePickerController.isSourceTypeAvailable(.camera) + } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -445,7 +452,7 @@ struct ImageFieldView: View { } Button { - showPicker = true + if cameraAvailable { showChoice = true } else { showLibrary = true } } label: { Label( selectedImage != nil || currentValue.hasPrefix("uploads/") @@ -459,10 +466,17 @@ struct ImageFieldView: View { } .buttonStyle(.plain) } - .sheet(isPresented: $showPicker) { - ImagePickerView(image: $selectedImage) { img in - saveAndCallback(img) - } + .confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) { + Button("Take Photo") { showCamera = true } + Button("Photo Library") { showLibrary = true } + Button("Cancel", role: .cancel) {} + } + .fullScreenCover(isPresented: $showCamera) { + CameraPickerView(image: $selectedImage, onSelected: saveAndCallback) + .ignoresSafeArea() + } + .sheet(isPresented: $showLibrary) { + LibraryPickerView(image: $selectedImage, onSelected: saveAndCallback) } } @@ -480,16 +494,24 @@ struct ImageFieldView: View { } // MARK: - ImagePickerView +// Retained as a thin typealias so existing call sites that reference +// ImagePickerView(image:onSelected:) continue to compile without changes. +// Internally it now just shows the library picker directly — callers that +// need the camera+library choice should use the inline pattern in ImageFieldView. +// NOTE: FlagIssueView and CompactImageFieldView have been updated to use +// the inline confirmationDialog pattern instead. +typealias ImagePickerView = LibraryPickerView -struct ImagePickerView: UIViewControllerRepresentable { +// ── Camera — UIImagePickerController with .camera source ───────────────────── + +struct CameraPickerView: UIViewControllerRepresentable { @Binding var image: UIImage? var onSelected: (UIImage) -> Void func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() + let picker = UIImagePickerController() + picker.sourceType = .camera picker.delegate = context.coordinator - picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) - ? .camera : .photoLibrary return picker } @@ -497,8 +519,8 @@ struct ImagePickerView: UIViewControllerRepresentable { func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { - let parent: ImagePickerView - init(_ parent: ImagePickerView) { self.parent = parent } + let parent: CameraPickerView + init(_ parent: CameraPickerView) { self.parent = parent } func imagePickerController( _ picker: UIImagePickerController, @@ -517,6 +539,45 @@ struct ImagePickerView: UIViewControllerRepresentable { } } +// ── Photo Library — PHPickerViewController (no permission required) ─────────── + +struct LibraryPickerView: UIViewControllerRepresentable { + @Binding var image: UIImage? + var onSelected: (UIImage) -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ vc: PHPickerViewController, context: Context) {} + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: LibraryPickerView + init(_ parent: LibraryPickerView) { self.parent = parent } + + func picker(_ picker: PHPickerViewController, + didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard let provider = results.first?.itemProvider, + provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { object, _ in + DispatchQueue.main.async { + if let img = object as? UIImage { + self.parent.image = img + self.parent.onSelected(img) + } + } + } + } + } +} + // MARK: - TableFieldView struct TableFieldView: View { diff --git a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift index c32f269..0e3993f 100644 --- a/JanitorialQC/Views/Inspection/InspectionHistoryView.swift +++ b/JanitorialQC/Views/Inspection/InspectionHistoryView.swift @@ -4,6 +4,7 @@ // Only available when online. Displays score, facility, template, and date. import SwiftUI +import SwiftData struct InspectionHistoryView: View { @@ -41,7 +42,11 @@ struct InspectionHistoryView: View { } else { List { ForEach(inspections) { inspection in - HistoryRowView(inspection: inspection) + NavigationLink { + HistoryDetailView(inspection: inspection) + } label: { + HistoryRowView(inspection: inspection) + } } // Load more @@ -181,3 +186,408 @@ struct HistoryRowView: View { .padding(.vertical, 4) } } + +// MARK: - History Detail View +// Shows submitted inspection details. +// For inspections originally submitted from this device (matched via mobileLocalId), +// the filled-in form responses are shown using the same grid as ExecuteInspectionView. +// For inspections submitted elsewhere, only summary fields are shown. + +struct HistoryDetailView: View { + + let inspection: APIInspectionSummary + + @Environment(\.modelContext) private var context + + // Look up the local copy by mobileLocalId — present only for this-device submissions + private var localCopy: LocalInspection? { + guard let lid = inspection.mobileLocalId else { return nil } + return try? context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.localId == lid } + ) + ).first + } + + // Fetch the template schema so we can render the form grid + private var localTemplate: LocalTemplate? { + guard let copy = localCopy else { return nil } + let id = copy.templateServerId + return try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.serverId == id }) + ).first + } + + private var formSchema: [[String: Any]] { localTemplate?.formSchema ?? [] } + + // Convert saved form data to [String: String] for the grid renderer + private var savedValues: [String: String] { + guard let copy = localCopy else { return [:] } + return copy.formData.compactMapValues { "\($0)" } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + + // ── Summary card ─────────────────────────────────────────── + summaryCard + + // ── Flagged issues ───────────────────────────────────────── + if let copy = localCopy, !copy.localIssues.isEmpty { + issuesCard(copy.localIssues) + } + + // ── Form responses ───────────────────────────────────────── + if !formSchema.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Form Responses") + .font(.headline) + .padding(.horizontal, 24) + + // Read-only form grid — reuses GridFormView with disabled inputs + ReadOnlyGridFormView( + schema: formSchema, + formValues: savedValues + ) + .padding(.horizontal, 24) + } + } else if localCopy != nil { + // Template schema no longer cached locally + infoRow( + icon: "doc.text", + text: "Form schema not available offline. Sync to view full responses." + ) + .padding(.horizontal, 24) + } + } + .padding(.vertical, 16) + } + .background(Color(.systemBackground)) + .navigationTitle(inspection.templateName) + .navigationBarTitleDisplayMode(.inline) + } + + // ── Summary card ─────────────────────────────────────────────────────── + + private var summaryCard: some View { + VStack(alignment: .leading, spacing: 12) { + + // Score + 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) + } + } + + Divider() + + infoRow(icon: "building.2", text: inspection.facilityName) + if let area = inspection.areaName { + infoRow(icon: "mappin", text: area) + } + if let date = inspection.inspectionDateParsed { + infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened)) + } + if inspection.mobileLocalId != nil { + infoRow(icon: "ipad", text: "Submitted from this device") + } + } + .padding(16) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 24) + } + + // ── Flagged issues card ──────────────────────────────────────────────── + + private func issuesCard(_ issues: [LocalIssue]) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("Flagged Issues (\(issues.count))") + .font(.headline) + + ForEach(issues) { issue in + HStack(alignment: .top, spacing: 10) { + 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, 5) + VStack(alignment: .leading, spacing: 2) { + Text(issue.severity.capitalized) + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(issue.issueDescription) + .font(.callout) + } + } + } + } + .padding(16) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 24) + } + + // ── Helper ───────────────────────────────────────────────────────────── + + private func infoRow(icon: String, text: String) -> some View { + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 18) + Text(text) + .font(.callout) + .foregroundStyle(.primary) + } + } +} + +// MARK: - ReadOnlyGridFormView +// Renders a submitted form in the same 12-column grid as ExecuteInspectionView +// but with all inputs disabled/display-only — no editing allowed. + +struct ReadOnlyGridFormView: View { + + let schema: [[String: Any]] + let formValues: [String: String] + + private struct ReadOnlyWidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } + } + + static let totalColumns: Int = 12 + static let cellGap: CGFloat = 8 + static let rowGap: CGFloat = 4 + static let cellAspect: CGFloat = 52/72 + static let cardPadding: CGFloat = 16 + + @State private var containerWidth: CGFloat = 952 + + private var computedCellW: CGFloat { + (containerWidth - 2 * Self.cardPadding + - CGFloat(Self.totalColumns - 1) * Self.cellGap) + / CGFloat(Self.totalColumns) + } + + private func canvasHeight() -> CGFloat { + let cellH = computedCellW * Self.cellAspect + let maxRow = schema.reduce(0) { acc, f in + max(acc, (f["row"] as? Int ?? 1) + (f["rowSpan"] as? Int ?? 2) - 1) + } + return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap + } + + var body: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 12) + .fill(Color(.secondarySystemBackground)) + + Color.clear + .frame(maxWidth: .infinity).frame(height: 0) + .background(GeometryReader { geo in + Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width) + }) + + let cellW = computedCellW + let cellH = cellW * Self.cellAspect + + ForEach(schema.indices, id: \.self) { idx in + let field = schema[idx] + let ftype = field["type"] as? String ?? "text" + if !["button_submit", "button_print", "button_email"].contains(ftype) { + readOnlyCell(field: field, cellW: cellW, cellH: cellH) + } + } + } + .onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } } + .frame(height: canvasHeight() + 2 * Self.cardPadding) + } + + @ViewBuilder + private func readOnlyCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View { + let col = max(1, field["col"] as? Int ?? 1) + let row = max(1, field["row"] as? Int ?? 1) + let colSpan = max(1, field["colSpan"] as? Int ?? 6) + let rowSpan = max(1, field["rowSpan"] as? Int ?? 2) + + let xOff = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding + let yOff = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding + let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap + let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap + + let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? "" + let value = formValues[fid] ?? "" + let ftype = field["type"] as? String ?? "text" + let label = field["label"] as? String ?? "" + + ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label) + .frame(width: width, height: height, alignment: .topLeading) + .offset(x: xOff, y: yOff) + } +} + +// MARK: - ReadOnlyCellView +// Displays a single form cell as plain text — no editable controls. + +struct ReadOnlyCellView: View { + let field: [String: Any] + let value: String + let fieldType: String + let label: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + // Label (same as GridCellContentView) + if !["section", "label", "checkbox", + "button_submit", "button_print", "button_email"].contains(fieldType), + !label.isEmpty { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color(.secondaryLabel)) + .lineLimit(1) + .truncationMode(.tail) + } + + // Value display + valueView + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + @ViewBuilder + private var valueView: some View { + switch fieldType { + + case "section": + VStack(alignment: .leading, spacing: 0) { + Divider() + Text(label) + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(Color(.label)) + .padding(.top, 4) + } + .frame(maxWidth: .infinity) + + case "label": + let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18] + let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13 + let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular + Text(field["text_content"] as? String ?? "") + .font(.system(size: fs, weight: fw)) + .foregroundStyle(Color(.label)) + .fixedSize(horizontal: false, vertical: true) + + case "checkbox": + HStack(spacing: 6) { + Image(systemName: value == "true" ? "checkmark.square.fill" : "square") + .foregroundStyle(value == "true" ? .blue : Color(.systemGray3)) + .font(.system(size: 14)) + Text(label) + .font(.system(size: 12)) + .foregroundStyle(Color(.secondaryLabel)) + } + + case "pass_fail": + let options = field["options"] as? [String] ?? ["Pass", "Fail"] + HStack(spacing: 6) { + ForEach(options, id: \.self) { opt in + let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased()) + let isActive = value == opt + Text(opt) + .font(.system(size: 12, weight: .semibold)) + .padding(.horizontal, 10).padding(.vertical, 4) + .background(isActive ? (isPass ? Color.green : Color.red) : Color.clear) + .foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red)) + .clipShape(Capsule()) + .overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5)) + } + } + + case "rating": + let intVal = Int(value) ?? 0 + let maxRating = field["max"] as? Int ?? 5 + HStack(spacing: 2) { + ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in + Text("★") + .font(.system(size: 16)) + .foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4)) + } + } + + case "image": + if value.hasPrefix("local://") { + // Photo taken on this device — may still be on disk + let path = String(value.dropFirst("local://".count)) + if let img = UIImage(contentsOfFile: path) { + Image(uiImage: img) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 5)) + } else { + // Local file cleaned up — show placeholder + Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } else if value.hasPrefix("uploads/") { + // Photo synced to server — load via AsyncImage + let url = URL(string: "\(Constants.baseURL)/static/\(value)") + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): + img.resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 5)) + case .failure: + Label("Could not load photo", systemImage: "photo.badge.exclamationmark") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + case .empty: + HStack(spacing: 6) { + ProgressView().scaleEffect(0.7) + Text("Loading photo…") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + @unknown default: + EmptyView() + } + } + } else if !value.isEmpty { + // Unknown path format — generic indicator + Label("Photo attached", systemImage: "photo") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else { + Text("—") + .font(.system(size: 12)) + .foregroundStyle(Color(.tertiaryLabel)) + } + + default: + // Text, textarea, number, email, date, select, radio, checkbox_group + Text(value.isEmpty ? "—" : value) + .font(.system(size: 12)) + .foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label)) + .lineLimit(3) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5) + .stroke(Color(.systemGray5), lineWidth: 1)) + } + } +}