diff --git a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift index 862e683..177e1ad 100644 --- a/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift +++ b/JanitorialQC/Views/Dashboard/ExecuteInspectionView.swift @@ -1,34 +1,40 @@ // Views/Inspection/ExecuteInspectionView.swift // -------------------------------------------- -// The primary work surface for completing an inspection. -// Renders the dynamic form_schema from the selected template. -// All writes go to SwiftData (offline-safe). Auto-saves every 30 seconds. +// Primary work surface for completing an inspection. +// Phase C fixes: +// 1. submitInspection() is async — shows success/failure toast, +// then navigates back to My Inspections on success. +// 2. Form fields rendered in a card-based layout suited for iPad. import SwiftUI import SwiftData struct ExecuteInspectionView: View { - @Environment(\.modelContext) private var context + @Environment(\.modelContext) private var context + @Environment(\.dismiss) private var dismiss @EnvironmentObject private var sync: SyncManager - @EnvironmentObject private var auth: AuthManager let inspection: LocalInspection - // Local state for the form — mirrors inspection.formData - @State private var formValues: [String: String] = [:] - @State private var showFlagIssue = false - @State private var showSubmitAlert = false - @State private var showOfflineBanner = false - @State private var isSaving = false - @State private var isSubmitting = false - @State private var submitMessage = "" + @State private var formValues: [String: String] = [:] + @State private var showFlagIssue = false + @State private var showSubmitAlert = false + @State private var isSaving = false + @State private var isSubmitting = false + @State private var submitResult: SubmitResult? - // Auto-save timer + // Auto-save interval private let autoSaveInterval: TimeInterval = 30 + enum SubmitResult { + case success(score: Double?) + case failure(String) + } + + // ── Computed ────────────────────────────────────────────────────────── + private var template: LocalTemplate? { - // Look up the template from SwiftData let id = inspection.templateServerId return try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.serverId == id }) @@ -42,119 +48,23 @@ struct ExecuteInspectionView: View { ).first } - private var formSchema: [[String: Any]] { - template?.formSchema ?? [] - } + private var formSchema: [[String: Any]] { template?.formSchema ?? [] } + + // ── Body ────────────────────────────────────────────────────────────── var body: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - - // ── Offline banner ───────────────────────────────────────── - if !sync.isOnline { - HStack { - Image(systemName: "wifi.slash") - Text("Offline — your work saves locally and will sync automatically.") - .font(.callout) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.orange.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.horizontal) - } - - // ── Inspection header ────────────────────────────────────── - VStack(alignment: .leading, spacing: 4) { - Text(template?.name ?? "Inspection Form") - .font(.title2.bold()) - Text(facility?.name ?? "") - .font(.subheadline) - .foregroundStyle(.secondary) - Text(inspection.inspectionDate.formatted(date: .long, time: .shortened)) - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding(.horizontal) - - Divider() - - // ── Form fields ──────────────────────────────────────────── - ForEach(formSchema.indices, id: \.self) { idx in - let field = formSchema[idx] - let fid = fieldId(field) - let ftype = field["type"] as? String ?? "" - - if !["button_submit", "button_print", "button_email"].contains(ftype) { - FormFieldView( - field: field, - value: Binding( - get: { formValues[fid] ?? "" }, - set: { formValues[fid] = $0; saveDraft() } - ), - onPhotoSelected: { localPath in - handlePhotoSelected(localPath: localPath, field: field) - } - ) - .padding(.horizontal) - } - } - - // ── Inspector notes ──────────────────────────────────────── - VStack(alignment: .leading, spacing: 6) { - Text("Inspector Notes") - .font(.subheadline.weight(.medium)) - TextEditor(text: Binding( - get: { inspection.inspectorNotes }, - set: { inspection.inspectorNotes = $0 } - )) - .frame(minHeight: 80) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4))) - } - .padding(.horizontal) - - Divider() - - // ── Action buttons ───────────────────────────────────────── - VStack(spacing: 12) { - // Flag Issue - Button { - showFlagIssue = true - } label: { - Label("Flag an Issue", systemImage: "exclamationmark.triangle") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.bordered) - .tint(.orange) - - // Save Draft - Button { - saveDraft(force: true) - } label: { - Label(isSaving ? "Saving…" : "Save Draft", systemImage: "square.and.arrow.down") - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.bordered) - .disabled(isSaving) - - // Submit Inspection - Button { - showSubmitAlert = true - } label: { - Label("Submit Inspection", systemImage: "checkmark.circle.fill") - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - } - .buttonStyle(.borderedProminent) - .disabled(isSubmitting) - } - .padding(.horizontal) - .padding(.bottom, 32) + // Centre content with max-width on iPad + VStack(alignment: .leading, spacing: 0) { + formContent } + .frame(maxWidth: 780) + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.vertical, 16) } - .navigationTitle("Inspection") + .background(Color(.systemGroupedBackground)) + .navigationTitle(template?.name ?? "Inspection") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -162,13 +72,11 @@ struct ExecuteInspectionView: View { } } .onAppear { - // Load saved form data into local state formValues = inspection.formData.compactMapValues { "\($0)" } } .onDisappear { - saveDraft(force: true) + saveDraft() } - // Auto-save every 30 seconds .task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(autoSaveInterval)) @@ -179,71 +87,303 @@ struct ExecuteInspectionView: View { FlagIssueView(inspection: inspection) } .alert("Submit Inspection", isPresented: $showSubmitAlert) { - Button("Submit", role: .none) { submitInspection() } + Button("Submit") { Task { await submitInspection() } } Button("Cancel", role: .cancel) {} } message: { - Text("Once submitted, the inspection cannot be edited. " + - (sync.isOnline - ? "It will be sent to the server now." - : "It will sync automatically when you're back online.")) + Text(sync.isOnline + ? "Once submitted the inspection cannot be edited. It will be sent to the server now." + : "Once submitted the inspection cannot be edited. It will sync automatically when you're back online.") + } + // Result overlay + .overlay(alignment: .top) { + if let result = submitResult { + submitResultBanner(result) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(10) + } + } + .animation(.spring(duration: 0.35), value: submitResult != nil) + } + + // ── Form Content ────────────────────────────────────────────────────── + + @ViewBuilder + private var formContent: some View { + + // Offline banner + if !sync.isOnline { + offlineBanner + .padding(.bottom, 12) + } + + // Inspection header card + headerCard + .padding(.bottom, 16) + + // Form fields grouped into cards by section + formFieldCards + .padding(.bottom, 16) + + // Inspector notes card + notesCard + .padding(.bottom, 16) + + // Action buttons + actionButtons + .padding(.bottom, 32) + } + + // ── Offline Banner ──────────────────────────────────────────────────── + + private var offlineBanner: some View { + HStack(spacing: 10) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text("Offline — your work saves locally and syncs automatically.") + .font(.callout) + .foregroundStyle(.orange) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + // ── Header Card ─────────────────────────────────────────────────────── + + private var headerCard: some View { + VStack(alignment: .leading, spacing: 6) { + Text(template?.name ?? "Inspection Form") + .font(.title2.bold()) + if let facilityName = facility?.name { + Label(facilityName, systemImage: "building.2") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Label( + inspection.inspectionDate.formatted(date: .long, time: .shortened), + systemImage: "calendar" + ) + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // ── Form Field Cards ────────────────────────────────────────────────── + // Groups fields into visual cards, starting a new card at each `section` field. + + private var formFieldCards: some View { + let groups = groupFieldsBySection(formSchema) + return ForEach(groups.indices, id: \.self) { groupIdx in + let group = groups[groupIdx] + fieldGroupCard(group) + .padding(.bottom, 12) } } - // ── Save Draft ───────────────────────────────────────────────────────── + private func fieldGroupCard(_ fields: [[String: Any]]) -> some View { + VStack(alignment: .leading, spacing: 14) { + ForEach(fields.indices, id: \.self) { idx in + let field = fields[idx] + let ftype = field["type"] as? String ?? "" + let fid = fieldId(field) + + if !["button_submit", "button_print", "button_email"].contains(ftype) { + FormFieldView( + field: field, + value: Binding( + get: { formValues[fid] ?? "" }, + set: { formValues[fid] = $0; saveDraft() } + ), + onPhotoSelected: { path in + handlePhotoSelected(localPath: path, field: field) + } + ) + + if idx < fields.count - 1 { + Divider() + } + } + } + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // ── Notes Card ──────────────────────────────────────────────────────── + + private var notesCard: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Inspector Notes", systemImage: "note.text") + .font(.subheadline.weight(.semibold)) + TextEditor(text: Binding( + get: { inspection.inspectorNotes }, + set: { inspection.inspectorNotes = $0 } + )) + .frame(minHeight: 100) + .padding(8) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray4), lineWidth: 1) + ) + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + // ── Action Buttons ──────────────────────────────────────────────────── + + private var actionButtons: some View { + VStack(spacing: 12) { + Button { + showFlagIssue = true + } label: { + Label("Flag an Issue", systemImage: "exclamationmark.triangle") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + .tint(.orange) + + Button { + saveDraft(force: true) + } label: { + Label(isSaving ? "Saving…" : "Save Draft", + systemImage: "square.and.arrow.down") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + .disabled(isSaving) + + Button { + showSubmitAlert = true + } label: { + Group { + if isSubmitting { + HStack(spacing: 8) { + ProgressView().tint(.white) + Text("Submitting…") + } + } else { + Label("Submit Inspection", systemImage: "checkmark.circle.fill") + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.borderedProminent) + .disabled(isSubmitting) + } + } + + // ── Submit Result Banner ────────────────────────────────────────────── + + private func submitResultBanner(_ result: SubmitResult) -> some View { + HStack(spacing: 12) { + switch result { + case .success(let score): + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundStyle(.green) + VStack(alignment: .leading, spacing: 2) { + Text("Inspection Submitted") + .font(.headline) + if let score { + Text(String(format: "Score: %.1f%%", score)) + .font(.callout) + .foregroundStyle(.secondary) + } + Text(sync.isOnline ? "Sent to server." : "Queued — will sync when online.") + .font(.caption) + .foregroundStyle(.secondary) + } + case .failure(let msg): + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundStyle(.red) + VStack(alignment: .leading, spacing: 2) { + Text("Submission Failed") + .font(.headline) + Text(msg) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer() + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.1), radius: 8, y: 4) + .padding(.horizontal, 24) + .padding(.top, 8) + } + + // ── Save Draft ──────────────────────────────────────────────────────── private func saveDraft(force: Bool = false) { guard inspection.status == "draft" else { return } if force { isSaving = true } - // Write form values back to the model var data: [String: Any] = [:] for (k, v) in formValues { data[k] = v } - inspection.formData = data + inspection.formData = data inspection.lastModifiedAt = Date() - try? context.save() + if force { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { isSaving = false } } } - // ── Submit ───────────────────────────────────────────────────────────── + // ── Submit (async — shows result, then dismisses) ───────────────────── - private func submitInspection() { + private func submitInspection() async { isSubmitting = true // Persist final form data var data: [String: Any] = [:] for (k, v) in formValues { data[k] = v } - inspection.formData = data - - // Compute score - inspection.overallScore = inspection.computeScore(fromSchema: formSchema) + inspection.formData = data + inspection.overallScore = inspection.computeScore(fromSchema: formSchema) inspection.status = "completed" inspection.completedAt = Date() inspection.syncStatus = "pending" - try? context.save() - // Trigger sync if online + isSubmitting = false + + // Show success banner + withAnimation { + submitResult = .success(score: inspection.overallScore) + } + + // Trigger sync in background if online if sync.isOnline { Task { await sync.triggerSync() } } - isSubmitting = false + // Wait 2.5 seconds so inspector reads the result, then dismiss + try? await Task.sleep(for: .seconds(2.5)) + dismiss() } - // ── Photo handling ───────────────────────────────────────────────────── + // ── Photo Handling ──────────────────────────────────────────────────── private func handlePhotoSelected(localPath: String, field: [String: Any]) { let fid = fieldId(field) - - // Store local sentinel in form values formValues[fid] = "local://\(localPath)" - - // Create PendingPhoto record let photo = PendingPhoto( localFilePath: localPath, entityType: "inspection", @@ -255,13 +395,32 @@ struct ExecuteInspectionView: View { try? context.save() } - // ── Helpers ──────────────────────────────────────────────────────────── + // ── Helpers ─────────────────────────────────────────────────────────── private func fieldId(_ field: [String: Any]) -> String { if let id = field["id"] as? String { return id } if let id = field["id"] as? Int { return String(id) } return UUID().uuidString } + + /// Groups form fields into arrays split at each `section` field. + /// Each resulting array begins with the section header (if any). + private func groupFieldsBySection(_ schema: [[String: Any]]) -> [[[String: Any]]] { + var groups: [[[String: Any]]] = [] + var current: [[String: Any]] = [] + + for field in schema { + let ftype = field["type"] as? String ?? "" + if ftype == "section" && !current.isEmpty { + groups.append(current) + current = [field] + } else { + current.append(field) + } + } + if !current.isEmpty { groups.append(current) } + return groups.isEmpty ? [[]] : groups + } } // MARK: - Connectivity Badge @@ -270,7 +429,7 @@ struct ConnectivityBadge: View { @EnvironmentObject private var sync: SyncManager var body: some View { - HStack(spacing: 4) { + HStack(spacing: 5) { Circle() .fill(sync.isOnline ? Color.green : Color.orange) .frame(width: 8, height: 8) diff --git a/JanitorialQC/Views/Dashboard/FormFieldView.swift b/JanitorialQC/Views/Dashboard/FormFieldView.swift index b70508d..4c4097c 100644 --- a/JanitorialQC/Views/Dashboard/FormFieldView.swift +++ b/JanitorialQC/Views/Dashboard/FormFieldView.swift @@ -1,7 +1,8 @@ // Views/Inspection/FormRenderer/FormFieldView.swift // ------------------------------------------------- -// Renders a single form field from the inspection template's form_schema. -// Supports all field types used by the JQC web app. +// Renders a single form field from the inspection template form_schema. +// Designed to sit inside a card (secondarySystemGroupedBackground). +// All fields use consistent styling suited for iPad. import SwiftUI import PencilKit @@ -11,84 +12,97 @@ import PencilKit struct FormFieldView: View { let field: [String: Any] - @Binding var value: String // All values stored as strings; lists as JSON - var onPhotoSelected: ((String) -> Void)? = nil // callback with local file path + @Binding var value: String + var onPhotoSelected: ((String) -> Void)? = nil - private var fieldType: String { field["type"] as? String ?? "text" } - private var label: String { field["label"] as? String ?? "" } - private var required: Bool { field["required"] as? Bool ?? false } + private var fieldType: String { field["type"] as? String ?? "text" } + private var label: String { field["label"] as? String ?? "" } + private var required: Bool { field["required"] as? Bool ?? false } private var placeholder: String { field["placeholder"] as? String ?? "" } var body: some View { - VStack(alignment: .leading, spacing: 6) { - - // Field label (skip for section/label types which render themselves) - if !["section", "label", "button_submit", "button_print", "button_email"] - .contains(fieldType), !label.isEmpty { - HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 8) { + // Label row — skip for types that render their own header + if !["section", "label", "checkbox", "button_submit", + "button_print", "button_email"].contains(fieldType), + !label.isEmpty { + HStack(spacing: 3) { Text(label) .font(.subheadline) .fontWeight(.medium) + .foregroundStyle(.primary) if required { - Text("*") - .foregroundStyle(.red) - .font(.subheadline) + Text("*").foregroundStyle(.red).font(.subheadline) } } } - // Field input fieldInput } - .padding(.vertical, 2) } @ViewBuilder private var fieldInput: some View { switch fieldType { + // ── Display-only ─────────────────────────────────────────────────── + case "section": Text(label) .font(.headline) .foregroundStyle(.blue) - .padding(.top, 8) + .padding(.top, 4) case "label": - let textContent = field["text_content"] as? String - ?? field["text"] as? String - ?? label - Text(textContent) + Text(field["text_content"] as? String + ?? field["text"] as? String + ?? label) .font(.callout) .foregroundStyle(.secondary) + // ── Text inputs ──────────────────────────────────────────────────── + case "text": - TextField(placeholder.isEmpty ? label : placeholder, text: $value) - .textFieldStyle(.roundedBorder) + styledTextField(placeholder: placeholder.isEmpty ? label : placeholder) case "textarea": TextEditor(text: $value) - .frame(minHeight: 80) - .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4))) + .frame(minHeight: 90) + .padding(8) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray4), lineWidth: 1)) case "number": - TextField(placeholder.isEmpty ? "0" : placeholder, text: $value) - .textFieldStyle(.roundedBorder) + styledTextField(placeholder: placeholder.isEmpty ? "0" : placeholder) .keyboardType(.decimalPad) case "email": - TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value) - .textFieldStyle(.roundedBorder) + styledTextField(placeholder: placeholder.isEmpty ? "email@example.com" : placeholder) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) + .autocorrectionDisabled() case "date": DateFieldView(value: $value) + // ── Selection ────────────────────────────────────────────────────── + case "checkbox": - Toggle(label, isOn: Binding( + Toggle(isOn: Binding( get: { value == "true" }, set: { value = $0 ? "true" : "false" } - )) + )) { + HStack(spacing: 3) { + Text(label) + .font(.subheadline) + .fontWeight(.medium) + if required { + Text("*").foregroundStyle(.red).font(.subheadline) + } + } + } case "checkbox_group": CheckboxGroupView(field: field, value: $value) @@ -99,6 +113,8 @@ struct FormFieldView: View { case "select": SelectFieldView(field: field, value: $value) + // ── Scoring ──────────────────────────────────────────────────────── + case "rating": RatingFieldView( maxRating: field["max"] as? Int ?? 5, @@ -111,8 +127,19 @@ struct FormFieldView: View { case "pass_fail": PassFailFieldView(value: $value) + // ── Rich inputs ──────────────────────────────────────────────────── + case "signature": - SignatureFieldView(value: $value) + VStack(alignment: .leading, spacing: 6) { + Text("Sign below") + .font(.caption) + .foregroundStyle(.secondary) + SignatureFieldView(value: $value) + .frame(height: 140) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray4), lineWidth: 1)) + } case "image": ImageFieldView( @@ -125,10 +152,20 @@ struct FormFieldView: View { TableFieldView(field: field, value: $value) default: - TextField(placeholder.isEmpty ? label : placeholder, text: $value) - .textFieldStyle(.roundedBorder) + styledTextField(placeholder: placeholder.isEmpty ? label : placeholder) } } + + // ── Shared styled text field ─────────────────────────────────────────── + + private func styledTextField(placeholder: String) -> some View { + TextField(placeholder, text: $value) + .padding(10) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray4), lineWidth: 1)) + } } // MARK: - DateFieldView @@ -139,12 +176,10 @@ struct DateFieldView: View { private var dateBinding: Binding { Binding( get: { - let formatter = ISO8601DateFormatter() - return formatter.date(from: value) ?? Date() + ISO8601DateFormatter().date(from: value) ?? Date() }, set: { - let formatter = ISO8601DateFormatter() - value = formatter.string(from: $0) + value = ISO8601DateFormatter().string(from: $0) } ) } @@ -152,6 +187,7 @@ struct DateFieldView: View { var body: some View { DatePicker("", selection: dateBinding, displayedComponents: .date) .labelsHidden() + .frame(maxWidth: .infinity, alignment: .leading) } } @@ -159,45 +195,41 @@ struct DateFieldView: View { struct CheckboxGroupView: View { let field: [String: Any] - @Binding var value: String // JSON array of selected values + @Binding var value: String - private var options: [String] { - field["options"] as? [String] ?? [] - } + private var options: [String] { field["options"] as? [String] ?? [] } - private var selectedValues: Set { - guard let data = value.data(using: .utf8), + private var selected: Set { + guard let data = value.data(using: .utf8), let array = try? JSONSerialization.jsonObject(with: data) as? [String] else { return [] } return Set(array) } private func toggle(_ option: String) { - var current = selectedValues - if current.contains(option) { - current.remove(option) - } else { - current.insert(option) - } - let sorted = options.filter { current.contains($0) } - if let data = try? JSONSerialization.data(withJSONObject: sorted), + var current = selected + if current.contains(option) { current.remove(option) } + else { current.insert(option) } + let ordered = options.filter { current.contains($0) } + if let data = try? JSONSerialization.data(withJSONObject: ordered), let str = String(data: data, encoding: .utf8) { value = str } } var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 10) { ForEach(options, id: \.self) { option in - Button { - toggle(option) - } label: { - HStack { - Image(systemName: selectedValues.contains(option) + Button { toggle(option) } label: { + HStack(spacing: 10) { + Image(systemName: selected.contains(option) ? "checkmark.square.fill" : "square") - .foregroundStyle(selectedValues.contains(option) ? .blue : .secondary) + .foregroundStyle(selected.contains(option) ? .blue : .secondary) + .font(.title3) Text(option) .foregroundStyle(.primary) + .font(.callout) + Spacer() } } .buttonStyle(.plain) @@ -212,22 +244,21 @@ struct RadioGroupView: View { let field: [String: Any] @Binding var value: String - private var options: [String] { - field["options"] as? [String] ?? [] - } + private var options: [String] { field["options"] as? [String] ?? [] } var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 10) { ForEach(options, id: \.self) { option in - Button { - value = option - } label: { - HStack { + Button { value = option } label: { + HStack(spacing: 10) { Image(systemName: value == option ? "largecircle.fill.circle" : "circle") .foregroundStyle(value == option ? .blue : .secondary) + .font(.title3) Text(option) .foregroundStyle(.primary) + .font(.callout) + Spacer() } } .buttonStyle(.plain) @@ -242,18 +273,30 @@ struct SelectFieldView: View { let field: [String: Any] @Binding var value: String - private var options: [String] { - field["options"] as? [String] ?? [] - } + private var options: [String] { field["options"] as? [String] ?? [] } var body: some View { - Picker("", selection: $value) { - Text("Select…").tag("") + Menu { + Button("— Select —") { value = "" } ForEach(options, id: \.self) { option in - Text(option).tag(option) + Button(option) { value = option } } + } label: { + HStack { + Text(value.isEmpty ? "Select…" : value) + .foregroundStyle(value.isEmpty ? Color(.placeholderText) : .primary) + .font(.callout) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .foregroundStyle(.secondary) + .font(.caption) + } + .padding(10) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(Color(.systemGray4), lineWidth: 1)) } - .pickerStyle(.menu) } } @@ -264,24 +307,25 @@ struct RatingFieldView: View { @Binding var value: Int var body: some View { - HStack(spacing: 8) { - ForEach(1...maxRating, id: \.self) { star in + HStack(spacing: 10) { + ForEach(1...max(maxRating, 1), id: \.self) { star in Button { - value = (value == star) ? 0 : star // tap same star to clear + value = (value == star) ? 0 : star } label: { Image(systemName: star <= value ? "star.fill" : "star") - .font(.title2) - .foregroundStyle(star <= value ? .yellow : .secondary) + .font(.title) + .foregroundStyle(star <= value ? .yellow : Color(.systemGray3)) } .buttonStyle(.plain) } if value > 0 { - Text("\(value)/\(maxRating)") - .font(.caption) + Text("\(value) / \(maxRating)") + .font(.callout) .foregroundStyle(.secondary) .padding(.leading, 4) } } + .padding(.vertical, 4) } } @@ -291,60 +335,70 @@ struct PassFailFieldView: View { @Binding var value: String var body: some View { - HStack(spacing: 12) { + HStack(spacing: 16) { Button { - value = value == "pass" ? "" : "pass" + value = (value == "pass") ? "" : "pass" } label: { - Label("Pass", systemImage: "checkmark.circle.fill") - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(value == "pass" ? Color.green : Color(.systemGray5)) - .foregroundStyle(value == "pass" ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 8)) + HStack(spacing: 6) { + Image(systemName: value == "pass" + ? "checkmark.circle.fill" : "checkmark.circle") + Text("Pass") + .fontWeight(.semibold) + } + .padding(.horizontal, 28) + .padding(.vertical, 12) + .background(value == "pass" ? Color.green : Color(.systemGray5)) + .foregroundStyle(value == "pass" ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) Button { - value = value == "fail" ? "" : "fail" + value = (value == "fail") ? "" : "fail" } label: { - Label("Fail", systemImage: "xmark.circle.fill") - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(value == "fail" ? Color.red : Color(.systemGray5)) - .foregroundStyle(value == "fail" ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 8)) + HStack(spacing: 6) { + Image(systemName: value == "fail" + ? "xmark.circle.fill" : "xmark.circle") + Text("Fail") + .fontWeight(.semibold) + } + .padding(.horizontal, 28) + .padding(.vertical, 12) + .background(value == "fail" ? Color.red : Color(.systemGray5)) + .foregroundStyle(value == "fail" ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 10)) } .buttonStyle(.plain) + + Spacer() } + .padding(.vertical, 4) } } // MARK: - SignatureFieldView struct SignatureFieldView: UIViewRepresentable { - @Binding var value: String // stored as base64 PNG data URL + @Binding var value: String func makeUIView(context: Context) -> PKCanvasView { let canvas = PKCanvasView() - canvas.drawingPolicy = .anyInput - canvas.backgroundColor = UIColor.systemBackground - canvas.layer.borderColor = UIColor.systemGray4.cgColor - canvas.layer.borderWidth = 1 - canvas.layer.cornerRadius = 6 + canvas.drawingPolicy = .anyInput + canvas.backgroundColor = UIColor.systemBackground + canvas.layer.cornerRadius = 8 canvas.delegate = context.coordinator return canvas } func updateUIView(_ canvas: PKCanvasView, context: Context) {} - func makeCoordinator() -> Coordinator { Coordinator(value: $value) } class Coordinator: NSObject, PKCanvasViewDelegate { var value: Binding init(value: Binding) { self.value = value } - func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) { - let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1) + func canvasViewDrawingDidChange(_ canvas: PKCanvasView) { + let image = canvas.drawing.image(from: canvas.bounds, scale: UIScreen.main.scale) if let data = image.pngData() { value.wrappedValue = "data:image/png;base64,\(data.base64EncodedString())" } @@ -359,27 +413,28 @@ struct ImageFieldView: View { let currentValue: String var onPhotoSelected: ((String) -> Void)? - @State private var showPicker = false + @State private var showPicker = false @State private var selectedImage: UIImage? var body: some View { - VStack(alignment: .leading, spacing: 8) { - // Preview + VStack(alignment: .leading, spacing: 10) { if let img = selectedImage { Image(uiImage: img) .resizable() .scaledToFit() - .frame(maxHeight: 200) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(maxHeight: 220) + .clipShape(RoundedRectangle(cornerRadius: 10)) } else if currentValue.hasPrefix("uploads/") { - // Already uploaded in a previous session — show a placeholder - HStack { + HStack(spacing: 8) { Image(systemName: "photo.fill") .foregroundStyle(.secondary) Text("Photo attached") .font(.callout) .foregroundStyle(.secondary) } + .padding(10) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } Button { @@ -390,8 +445,12 @@ struct ImageFieldView: View { ? "Replace Photo" : "Attach Photo", systemImage: "camera" ) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color(.systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } - .buttonStyle(.bordered) + .buttonStyle(.plain) } .sheet(isPresented: $showPicker) { ImagePickerView(image: $selectedImage) { img in @@ -402,15 +461,14 @@ struct ImageFieldView: View { private func saveAndCallback(_ img: UIImage) { guard let data = img.jpegData(compressionQuality: 0.8) else { return } - let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let photosDir = docsDir.appendingPathComponent("JQC/Photos", isDirectory: true) + 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 fileURL = photosDir.appendingPathComponent(filename) - try? data.write(to: fileURL) + let url = photosDir.appendingPathComponent("\(UUID().uuidString).jpg") + try? data.write(to: url) selectedImage = img - onPhotoSelected?(fileURL.path) + onPhotoSelected?(url.path) } } @@ -422,9 +480,9 @@ struct ImagePickerView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() - picker.delegate = context.coordinator - picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) - ? .camera : .photoLibrary + picker.delegate = context.coordinator + picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) + ? .camera : .photoLibrary return picker } @@ -445,6 +503,10 @@ struct ImagePickerView: UIViewControllerRepresentable { } picker.dismiss(animated: true) } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + } } } @@ -452,26 +514,21 @@ struct ImagePickerView: UIViewControllerRepresentable { struct TableFieldView: View { let field: [String: Any] - @Binding var value: String // JSON: [[String: String]] + @Binding var value: String - private var columns: [String] { - field["col_headers"] as? [String] ?? ["Column 1"] - } - private var rowCount: Int { - field["table_rows"] as? Int ?? 3 - } + private var columns: [String] { field["col_headers"] as? [String] ?? ["Column 1"] } + private var rowCount: Int { field["table_rows"] as? Int ?? 3 } private var tableData: [[String: String]] { - get { - guard let data = value.data(using: .utf8), - let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] - else { - // Initialize empty table - return Array(repeating: Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }), - count: rowCount) - } - return array + guard let data = value.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] + else { + return Array( + repeating: Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }), + count: rowCount + ) } + return array } private func updateCell(row: Int, col: String, newValue: String) { @@ -487,36 +544,43 @@ struct TableFieldView: View { } var body: some View { - ScrollView(.horizontal) { - Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) { - // Header row - GridRow { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack(spacing: 0) { ForEach(columns, id: \.self) { col in Text(col) - .font(.caption) - .fontWeight(.semibold) + .font(.caption.bold()) .foregroundStyle(.secondary) + .frame(minWidth: 120, alignment: .leading) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(Color(.systemGray6)) } } Divider() - // Data rows - ForEach(0..