05/04 Update the app functionalities
This commit is contained in:
@@ -37,10 +37,12 @@ private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
|
|||||||
private enum CodingKeys: String, CodingKey { case ok, data, error }
|
private enum CodingKeys: String, CodingKey { case ok, data, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh-only envelope — uses a non-Sendable-constrained local struct
|
// Free function removed — see refreshAccessToken() which decodes using a
|
||||||
// decoded manually to avoid pulling RefreshResponseData into the Sendable chain.
|
// local JSONDecoder to avoid Swift 6 actor-isolation errors.
|
||||||
private struct _RefreshEnvelope: Decodable {
|
|
||||||
struct Tokens: Decodable {
|
// 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 accessToken: String
|
||||||
let refreshToken: String
|
let refreshToken: String
|
||||||
}
|
}
|
||||||
@@ -170,7 +172,7 @@ actor APIClient {
|
|||||||
|
|
||||||
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
func submitIssue(_ issue: LocalIssue) async throws -> Int {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"area_id": issue.areaServerId,
|
"facility_id": issue.facilityServerId,
|
||||||
"severity": issue.severity,
|
"severity": issue.severity,
|
||||||
"description": issue.issueDescription,
|
"description": issue.issueDescription,
|
||||||
"mobile_local_id": issue.localId,
|
"mobile_local_id": issue.localId,
|
||||||
@@ -199,7 +201,11 @@ actor APIClient {
|
|||||||
let http = response as? HTTPURLResponse, http.statusCode == 200
|
let http = response as? HTTPURLResponse, http.statusCode == 200
|
||||||
else { return false }
|
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,
|
env.ok,
|
||||||
let tokens = env.data
|
let tokens = env.data
|
||||||
else { return false }
|
else { return false }
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ final class LocalIssue {
|
|||||||
var serverId: Int?
|
var serverId: Int?
|
||||||
|
|
||||||
var inspectionLocalId: String // references LocalInspection.localId
|
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 severity: String // "low" | "medium" | "high" | "critical"
|
||||||
var issueDescription: String
|
var issueDescription: String
|
||||||
var photoLocalPath: String? // local file path before upload
|
var photoLocalPath: String? // local file path before upload
|
||||||
@@ -27,14 +27,14 @@ final class LocalIssue {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
inspectionLocalId: String,
|
inspectionLocalId: String,
|
||||||
areaServerId: Int,
|
facilityServerId: Int,
|
||||||
severity: String,
|
severity: String,
|
||||||
description: String
|
description: String
|
||||||
) {
|
) {
|
||||||
self.localId = UUID().uuidString
|
self.localId = UUID().uuidString
|
||||||
self.serverId = nil
|
self.serverId = nil
|
||||||
self.inspectionLocalId = inspectionLocalId
|
self.inspectionLocalId = inspectionLocalId
|
||||||
self.areaServerId = areaServerId
|
self.facilityServerId = facilityServerId
|
||||||
self.severity = severity
|
self.severity = severity
|
||||||
self.issueDescription = description
|
self.issueDescription = description
|
||||||
self.photoLocalPath = nil
|
self.photoLocalPath = nil
|
||||||
|
|||||||
@@ -2,11 +2,29 @@
|
|||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// Phase C: adds Inspection History tab, polished Settings with cache clear,
|
// Phase C: adds Inspection History tab, polished Settings with cache clear,
|
||||||
// and schedules background sync on scene enter background.
|
// 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 SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Combine
|
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 {
|
struct DashboardView: View {
|
||||||
|
|
||||||
@EnvironmentObject private var auth: AuthManager
|
@EnvironmentObject private var auth: AuthManager
|
||||||
@@ -20,17 +38,17 @@ struct DashboardView: View {
|
|||||||
order: .reverse
|
order: .reverse
|
||||||
) private var myInspections: [LocalInspection]
|
) private var myInspections: [LocalInspection]
|
||||||
|
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab: SidebarTab = .myInspections
|
||||||
@State private var showNewInspection = false
|
@State private var showNewInspection = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List {
|
List {
|
||||||
// My Inspections
|
// ── My Inspections ─────────────────────────────────────────
|
||||||
Button { selectedTab = 0 } label: {
|
Button { selectedTab = .myInspections } label: {
|
||||||
HStack {
|
HStack {
|
||||||
Label("My Inspections", systemImage: "checklist")
|
Label("My Inspections", systemImage: "checklist")
|
||||||
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
|
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if !myInspections.isEmpty {
|
if !myInspections.isEmpty {
|
||||||
Text("\(myInspections.count)")
|
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
|
// ── Issues (all roles) ─────────────────────────────────────
|
||||||
Button { selectedTab = 1 } label: {
|
Button { selectedTab = .issues } label: {
|
||||||
Label("History", systemImage: "clock.arrow.circlepath")
|
Label("Issues", systemImage: "exclamationmark.triangle")
|
||||||
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
|
.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
|
// ── Facilities ─────────────────────────────────────────────
|
||||||
Button { selectedTab = 2 } label: {
|
Button { selectedTab = .facilities } label: {
|
||||||
Label("Facilities", systemImage: "building.2")
|
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
|
// ── Pending Sync ───────────────────────────────────────────
|
||||||
Button { selectedTab = 3 } label: {
|
Button { selectedTab = .pendingSync } 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: {
|
|
||||||
HStack {
|
HStack {
|
||||||
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
||||||
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
|
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
|
||||||
Spacer()
|
Spacer()
|
||||||
if sync.pendingCount > 0 {
|
if sync.pendingCount > 0 {
|
||||||
Text("\(sync.pendingCount)")
|
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
|
// ── History (moved — sits between Pending Sync and Settings)
|
||||||
Button { selectedTab = 5 } label: {
|
Button { selectedTab = .history } label: {
|
||||||
Label("Settings", systemImage: "gear")
|
Label("History", systemImage: "clock.arrow.circlepath")
|
||||||
.foregroundStyle(selectedTab == 5 ? .blue : .primary)
|
.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")
|
.navigationTitle("JQC Inspector")
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
@@ -101,17 +119,13 @@ struct DashboardView: View {
|
|||||||
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
||||||
|
|
||||||
} detail: {
|
} 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 {
|
switch selectedTab {
|
||||||
case 0: NavigationStack { MyInspectionsView() }
|
case .myInspections: NavigationStack { MyInspectionsView() }
|
||||||
case 1: NavigationStack { InspectionHistoryView() }
|
case .issues: NavigationStack { IssuesListView() }
|
||||||
case 2: NavigationStack { FacilitiesListView() }
|
case .facilities: NavigationStack { FacilitiesListView() }
|
||||||
case 3: NavigationStack { TemplatesListView() }
|
case .pendingSync: NavigationStack { SyncStatusView() }
|
||||||
case 4: NavigationStack { SyncStatusView() }
|
case .history: NavigationStack { InspectionHistoryView() }
|
||||||
default: NavigationStack { SettingsView() }
|
case .settings: NavigationStack { SettingsView() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewInspection) {
|
.sheet(isPresented: $showNewInspection) {
|
||||||
@@ -169,6 +183,10 @@ struct MyInspectionsView: View {
|
|||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
// Deletion confirmation state
|
||||||
|
@State private var pendingDelete: LocalInspection?
|
||||||
|
@State private var showDeleteAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if inspections.isEmpty {
|
if inspections.isEmpty {
|
||||||
@@ -188,10 +206,55 @@ struct MyInspectionsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
InspectionRowView(inspection: inspection, context: context)
|
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")
|
.navigationTitle("My Inspections")
|
||||||
|
// Confirmation before deletion — destructive action cannot be undone
|
||||||
|
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
|
||||||
|
Button("Delete", role: .destructive) { deleteDraft(inspection) }
|
||||||
|
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||||
|
} message: { inspection in
|
||||||
|
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func draftName(_ inspection: LocalInspection) -> String {
|
||||||
|
let templateId = inspection.templateServerId // plain Int — safe to capture in #Predicate
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalTemplate>(
|
||||||
|
predicate: #Predicate { $0.serverId == templateId }
|
||||||
|
)
|
||||||
|
).first?.name) ?? "this inspection"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteDraft(_ inspection: LocalInspection) {
|
||||||
|
// Delete associated pending photos from disk and SwiftData
|
||||||
|
for photo in inspection.pendingPhotos {
|
||||||
|
try? FileManager.default.removeItem(atPath: photo.localFilePath)
|
||||||
|
context.delete(photo)
|
||||||
|
}
|
||||||
|
// Delete associated local issues
|
||||||
|
for issue in inspection.localIssues {
|
||||||
|
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<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch issue.severity {
|
||||||
|
case "critical": return .red
|
||||||
|
case "high": return .orange
|
||||||
|
case "medium": return .yellow
|
||||||
|
default: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(severityColor)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack {
|
||||||
|
Text(issue.severity.capitalized)
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(severityColor)
|
||||||
|
Spacer()
|
||||||
|
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
|
||||||
|
}
|
||||||
|
Text(issue.issueDescription)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(2)
|
||||||
|
Text(facilityName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IssueDetailView: View {
|
||||||
|
let issue: LocalIssue
|
||||||
|
@Environment(\.modelContext) private var context
|
||||||
|
|
||||||
|
private var facilityName: String {
|
||||||
|
let id = issue.facilityServerId
|
||||||
|
return (try? context.fetch(
|
||||||
|
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||||
|
).first?.name) ?? "Unknown Facility"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var severityColor: Color {
|
||||||
|
switch issue.severity {
|
||||||
|
case "critical": return .red
|
||||||
|
case "high": return .orange
|
||||||
|
case "medium": return .yellow
|
||||||
|
default: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
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
|
// MARK: - Templates
|
||||||
|
|
||||||
struct TemplatesListView: View {
|
struct TemplatesListView: View {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct ExecuteInspectionView: View {
|
|||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemBackground))
|
||||||
.navigationTitle(template?.name ?? "Inspection")
|
.navigationTitle(template?.name ?? "Inspection")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -140,10 +140,6 @@ struct ExecuteInspectionView: View {
|
|||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspector notes card
|
|
||||||
notesCard
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
// Action buttons
|
// Action buttons
|
||||||
actionButtons
|
actionButtons
|
||||||
.padding(.bottom, 32)
|
.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
|
// MARK: - GridFormView
|
||||||
// CHANGED: new view — renders the form schema using the same 12-column grid
|
// Renders the form schema using the same 12-column grid as the web app's
|
||||||
// layout as the web app's .form-grid CSS grid. Each field is positioned using
|
// .form-grid CSS grid. Each field is positioned using col/row/colSpan/rowSpan.
|
||||||
// its col/row/colSpan/rowSpan attributes from the JSON schema.
|
|
||||||
|
|
||||||
struct GridFormView: View {
|
struct GridFormView: View {
|
||||||
|
|
||||||
@@ -407,38 +414,58 @@ struct GridFormView: View {
|
|||||||
var onPhotoSelected: ((String, [String: Any]) -> Void)?
|
var onPhotoSelected: ((String, [String: Any]) -> Void)?
|
||||||
var onFieldChanged: (() -> Void)?
|
var onFieldChanged: (() -> Void)?
|
||||||
|
|
||||||
// Grid constants — match the web app
|
// ── 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 totalColumns: Int = 12
|
||||||
static let cellGap: CGFloat = 4 // column gap (web: 4px)
|
static let cellGap: CGFloat = 8 // column gap — matches editor GAP=8 and CSS col-gap
|
||||||
static let rowGap: CGFloat = 4 // row gap (web: 4px)
|
static let rowGap: CGFloat = 4 // row gap — matches CSS row-gap
|
||||||
static let cellAspect: CGFloat = 52/72 // cellH / cellW (web: 52px / 72px)
|
static let cellAspect: CGFloat = 52/72 // cellH / cellW — matches editor CELL_H/CELL_W
|
||||||
static let cardPadding: CGFloat = 16 // card inset on all sides
|
static let cardPadding: CGFloat = 16 // card inset on all sides
|
||||||
|
|
||||||
// @State to capture the rendered grid width from the background GeometryReader.
|
// Minimum cell height (points) per field type — ensures 44pt touch targets
|
||||||
// Starts at a reasonable iPad default (952 = 1000 max-width − 2×24 outer padding).
|
// on iPad even when the template author assigned a very short rowSpan.
|
||||||
@State private var gridWidth: CGFloat = 952
|
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 {
|
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) {
|
ZStack(alignment: .topLeading) {
|
||||||
// Card background
|
// ── Card background ────────────────────────────────────────────
|
||||||
RoundedRectangle(cornerRadius: 12)
|
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
|
Color.clear
|
||||||
.frame(height: 1)
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 0)
|
||||||
.background(
|
.background(
|
||||||
GeometryReader { geo in
|
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
|
// ── Field overlays ─────────────────────────────────────────────
|
||||||
let cellW = (gridWidth - 2 * Self.cardPadding
|
let cellW = computedCellW
|
||||||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
|
||||||
/ CGFloat(Self.totalColumns)
|
|
||||||
let cellH = cellW * Self.cellAspect
|
let cellH = cellW * Self.cellAspect
|
||||||
|
|
||||||
ForEach(schema.indices, id: \.self) { idx in
|
ForEach(schema.indices, id: \.self) { idx in
|
||||||
@@ -449,17 +476,26 @@ struct GridFormView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Explicit height derived from the same cellW/cellH arithmetic —
|
.onPreferenceChange(WidthPreferenceKey.self) { width in
|
||||||
// this is what the ScrollView measures, so it can never be wrong.
|
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)
|
.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 ─────────────────────────────────────────────────────
|
// ── Canvas height ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private func canvasHeight() -> CGFloat {
|
private func canvasHeight() -> CGFloat {
|
||||||
let cellW = (gridWidth - 2 * Self.cardPadding
|
let cellW = computedCellW
|
||||||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
|
||||||
/ CGFloat(Self.totalColumns)
|
|
||||||
let cellH = cellW * Self.cellAspect
|
let cellH = cellW * Self.cellAspect
|
||||||
let maxRow = schema.reduce(0) { acc, f in
|
let maxRow = schema.reduce(0) { acc, f in
|
||||||
let r = f["row"] as? Int ?? 1
|
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 yOffset = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
|
||||||
|
|
||||||
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
|
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)
|
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
|
fieldInput
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
|
||||||
// ── Help text — matches .help-text ──
|
// ── Help text — matches .help-text ──
|
||||||
if !helpText.isEmpty {
|
if !helpText.isEmpty {
|
||||||
@@ -556,7 +599,9 @@ struct GridCellContentView: View {
|
|||||||
.lineLimit(2)
|
.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
|
@ViewBuilder
|
||||||
@@ -661,8 +706,9 @@ struct GridCellContentView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ── Image / Photo upload ──────────────────────────────────────────
|
// ── Image / Photo upload ──────────────────────────────────────────
|
||||||
|
// Uses a compact inline zone to match the web's .upload-zone dashed style.
|
||||||
case "image":
|
case "image":
|
||||||
ImageFieldView(
|
CompactImageFieldView(
|
||||||
fieldId: field["id"] as? String ?? UUID().uuidString,
|
fieldId: field["id"] as? String ?? UUID().uuidString,
|
||||||
currentValue: value,
|
currentValue: value,
|
||||||
onPhotoSelected: onPhotoSelected
|
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
|
// MARK: - CellDatePicker
|
||||||
// Compact date picker for a grid cell — shows a short date format.
|
// Compact date picker for a grid cell — shows a short date format.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
// Views/Inspection/FlagIssueView.swift
|
// Views/Dashboard/FlagIssueView.swift
|
||||||
// -------------------------------------
|
// ------------------------------------
|
||||||
// Sheet for flagging an issue during an inspection.
|
// Sheet for flagging an issue during an inspection.
|
||||||
// Saves locally immediately; syncs to server when online.
|
// 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 SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -14,42 +19,55 @@ struct FlagIssueView: View {
|
|||||||
|
|
||||||
let inspection: LocalInspection
|
let inspection: LocalInspection
|
||||||
|
|
||||||
@State private var selectedAreaId: Int?
|
|
||||||
@State private var severity = "medium"
|
@State private var severity = "medium"
|
||||||
@State private var description = ""
|
@State private var description = ""
|
||||||
@State private var selectedImage: UIImage?
|
@State private var selectedImage: UIImage?
|
||||||
@State private var photoLocalPath: String?
|
@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 let severities = ["low", "medium", "high", "critical"]
|
||||||
|
|
||||||
private var areas: [LocalArea] {
|
private var facility: LocalFacility? {
|
||||||
let facilityId = inspection.facilityServerId
|
let id = inspection.facilityServerId
|
||||||
let results = try? context.fetch(
|
return try? context.fetch(
|
||||||
FetchDescriptor<LocalArea>(
|
FetchDescriptor<LocalFacility>(
|
||||||
predicate: #Predicate { $0.facilityServerId == facilityId },
|
predicate: #Predicate { $0.serverId == id }
|
||||||
sortBy: [SortDescriptor(\.name)]
|
|
||||||
)
|
)
|
||||||
)
|
).first
|
||||||
return results ?? []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canSubmit: Bool {
|
private var canSubmit: Bool {
|
||||||
selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty
|
!description.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
// ── Area ───────────────────────────────────────────────────
|
// ── Facility (read-only) — matches web alert banner ────────
|
||||||
Section("Area") {
|
Section {
|
||||||
Picker("Area", selection: $selectedAreaId) {
|
HStack(spacing: 10) {
|
||||||
Text("Select area…").tag(Optional<Int>(nil))
|
Image(systemName: "building.2")
|
||||||
ForEach(areas) { area in
|
.foregroundStyle(.secondary)
|
||||||
Text(area.name).tag(Optional(area.serverId))
|
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 ───────────────────────────────────────────────
|
// ── Severity ───────────────────────────────────────────────
|
||||||
@@ -78,7 +96,7 @@ struct FlagIssueView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
showImagePicker = true
|
if cameraAvailable { showChoice = true } else { showLibrary = true }
|
||||||
} label: {
|
} label: {
|
||||||
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
|
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
|
||||||
systemImage: "camera")
|
systemImage: "camera")
|
||||||
@@ -107,10 +125,17 @@ struct FlagIssueView: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showImagePicker) {
|
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
|
||||||
ImagePickerView(image: $selectedImage) { img in
|
Button("Take Photo") { showCamera = true }
|
||||||
savePhoto(img)
|
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() {
|
private func submitIssue() {
|
||||||
guard let areaId = selectedAreaId else { return }
|
|
||||||
|
|
||||||
let issue = LocalIssue(
|
let issue = LocalIssue(
|
||||||
inspectionLocalId: inspection.localId,
|
inspectionLocalId: inspection.localId,
|
||||||
areaServerId: areaId,
|
facilityServerId: inspection.facilityServerId,
|
||||||
severity: severity,
|
severity: severity,
|
||||||
description: description.trimmingCharacters(in: .whitespaces)
|
description: description.trimmingCharacters(in: .whitespaces)
|
||||||
)
|
)
|
||||||
@@ -142,7 +165,6 @@ struct FlagIssueView: View {
|
|||||||
inspection.localIssues.append(issue)
|
inspection.localIssues.append(issue)
|
||||||
context.insert(issue)
|
context.insert(issue)
|
||||||
|
|
||||||
// Create PendingPhoto if a photo was attached
|
|
||||||
if let path = photoLocalPath {
|
if let path = photoLocalPath {
|
||||||
let photo = PendingPhoto(
|
let photo = PendingPhoto(
|
||||||
localFilePath: path,
|
localFilePath: path,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import PencilKit
|
import PencilKit
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
// MARK: - FormFieldView
|
// MARK: - FormFieldView
|
||||||
// Retained for standalone/legacy usage outside the grid inspection form.
|
// Retained for standalone/legacy usage outside the grid inspection form.
|
||||||
@@ -420,8 +421,14 @@ struct ImageFieldView: View {
|
|||||||
let currentValue: String
|
let currentValue: String
|
||||||
var onPhotoSelected: ((String) -> Void)?
|
var onPhotoSelected: ((String) -> Void)?
|
||||||
|
|
||||||
@State private var showPicker = false
|
|
||||||
@State private var selectedImage: UIImage?
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
@@ -445,7 +452,7 @@ struct ImageFieldView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showPicker = true
|
if cameraAvailable { showChoice = true } else { showLibrary = true }
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Label(
|
||||||
selectedImage != nil || currentValue.hasPrefix("uploads/")
|
selectedImage != nil || currentValue.hasPrefix("uploads/")
|
||||||
@@ -459,10 +466,17 @@ struct ImageFieldView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPicker) {
|
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
|
||||||
ImagePickerView(image: $selectedImage) { img in
|
Button("Take Photo") { showCamera = true }
|
||||||
saveAndCallback(img)
|
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
|
// 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?
|
@Binding var image: UIImage?
|
||||||
var onSelected: (UIImage) -> Void
|
var onSelected: (UIImage) -> Void
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
let picker = UIImagePickerController()
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = .camera
|
||||||
picker.delegate = context.coordinator
|
picker.delegate = context.coordinator
|
||||||
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
|
|
||||||
? .camera : .photoLibrary
|
|
||||||
return picker
|
return picker
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,8 +519,8 @@ struct ImagePickerView: UIViewControllerRepresentable {
|
|||||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||||
|
|
||||||
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
let parent: ImagePickerView
|
let parent: CameraPickerView
|
||||||
init(_ parent: ImagePickerView) { self.parent = parent }
|
init(_ parent: CameraPickerView) { self.parent = parent }
|
||||||
|
|
||||||
func imagePickerController(
|
func imagePickerController(
|
||||||
_ picker: UIImagePickerController,
|
_ 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
|
// MARK: - TableFieldView
|
||||||
|
|
||||||
struct TableFieldView: View {
|
struct TableFieldView: View {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// Only available when online. Displays score, facility, template, and date.
|
// Only available when online. Displays score, facility, template, and date.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct InspectionHistoryView: View {
|
struct InspectionHistoryView: View {
|
||||||
|
|
||||||
@@ -41,8 +42,12 @@ struct InspectionHistoryView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(inspections) { inspection in
|
ForEach(inspections) { inspection in
|
||||||
|
NavigationLink {
|
||||||
|
HistoryDetailView(inspection: inspection)
|
||||||
|
} label: {
|
||||||
HistoryRowView(inspection: inspection)
|
HistoryRowView(inspection: inspection)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load more
|
// Load more
|
||||||
if inspections.count < total {
|
if inspections.count < total {
|
||||||
@@ -181,3 +186,408 @@ struct HistoryRowView: View {
|
|||||||
.padding(.vertical, 4)
|
.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<LocalInspection>(
|
||||||
|
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<LocalTemplate>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user