05/02 Phase B

This commit is contained in:
Nguyen Ngo
2026-05-02 11:46:28 -04:00
parent ef0a6448cf
commit 8bdcbecd73
22 changed files with 3262 additions and 69 deletions
@@ -0,0 +1,581 @@
// Views/Dashboard/DashboardView.swift
// ------------------------------------
// Phase B: adds My Inspections list and Pending Sync status to the sidebar.
import SwiftUI
import SwiftData
import Combine
struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab = 0
@State private var showNewInspection = false
var body: some View {
NavigationSplitView {
// Sidebar
List {
Button { selectedTab = 0 } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.15))
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 1 } label: {
Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
}
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 2 } label: {
Label("Templates", systemImage: "doc.text")
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
}
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 3 } label: {
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange.opacity(0.2))
.foregroundStyle(.orange)
.clipShape(Capsule())
}
}
}
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
Button { selectedTab = 4 } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
}
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewInspection = true
} label: {
Image(systemName: "plus")
}
}
}
.safeAreaInset(edge: .bottom) {
syncStatusFooter
}
} detail: {
switch selectedTab {
case 0: MyInspectionsView()
case 1: FacilitiesListView()
case 2: TemplatesListView()
case 3: SyncStatusView()
default: SettingsView()
}
}
.sheet(isPresented: $showNewInspection) {
StartInspectionView()
}
.task {
if sync.isOnline {
await sync.triggerSync()
} else {
sync.updatePendingCount(context: context)
}
}
}
// Sync Status Footer
private var syncStatusFooter: some View {
VStack(spacing: 0) {
Divider()
HStack(spacing: 8) {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if sync.isSyncing {
ProgressView()
.scaleEffect(0.7)
} else if let lastSync = sync.lastSyncAt {
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
// MARK: - My Inspections
struct MyInspectionsView: View {
@Query(
filter: #Predicate<LocalInspection> { $0.status != "synced" },
sort: \LocalInspection.lastModifiedAt,
order: .reverse
) private var inspections: [LocalInspection]
@Environment(\.modelContext) private var context
var body: some View {
Group {
if inspections.isEmpty {
ContentUnavailableView(
"No Inspections",
systemImage: "checklist",
description: Text("Tap + to start a new inspection.")
)
} else {
List(inspections) { inspection in
NavigationLink {
if inspection.status == "draft" {
ExecuteInspectionView(inspection: inspection)
} else {
CompletedInspectionView(inspection: inspection)
}
} label: {
InspectionRowView(inspection: inspection, context: context)
}
}
}
}
.navigationTitle("My Inspections")
}
}
struct InspectionRowView: View {
let inspection: LocalInspection
let context: ModelContext
private var facilityName: String {
let id = inspection.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Template"
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(templateName)
.font(.headline)
Spacer()
StatusBadge(status: inspection.status, syncStatus: inspection.syncStatus)
}
Text(facilityName)
.font(.callout)
.foregroundStyle(.secondary)
HStack {
Text(inspection.inspectionDate.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
if let score = inspection.overallScore {
Spacer()
Text(String(format: "%.1f%%", score))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
}
.padding(.vertical, 4)
}
}
struct StatusBadge: View {
let status: String
let syncStatus: String
var label: String {
switch status {
case "draft": return "Draft"
case "completed": return syncStatus == "pending" ? "Pending Sync" : "Completed"
case "sync_failed": return "Sync Failed"
default: return status.capitalized
}
}
var color: Color {
switch status {
case "draft": return .blue
case "completed": return syncStatus == "pending" ? .orange : .green
case "sync_failed": return .red
default: return .secondary
}
}
var body: some View {
Text(label)
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(color.opacity(0.15))
.foregroundStyle(color)
.clipShape(Capsule())
}
}
// MARK: - Completed Inspection (read-only view)
struct CompletedInspectionView: View {
let inspection: LocalInspection
@Environment(\.modelContext) private var context
private var templateName: String {
let id = inspection.templateServerId
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Inspection"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Summary card
GroupBox {
VStack(alignment: .leading, spacing: 8) {
if let score = inspection.overallScore {
HStack {
Text("Overall Score")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.1f%%", score))
.font(.title2.bold())
.foregroundStyle(score >= 80 ? .green : score >= 60 ? .orange : .red)
}
}
if let completedAt = inspection.completedAt {
HStack {
Text("Completed")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(completedAt.formatted(date: .abbreviated, time: .shortened))
.font(.callout)
}
}
HStack {
Text("Sync Status")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
StatusBadge(status: inspection.status,
syncStatus: inspection.syncStatus)
}
if let error = inspection.syncErrorMessage {
Text("Error: \(error)")
.font(.caption)
.foregroundStyle(.red)
}
}
}
.padding(.horizontal)
// Issues
if !inspection.localIssues.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Flagged Issues (\(inspection.localIssues.count))")
.font(.headline)
.padding(.horizontal)
ForEach(inspection.localIssues) { issue in
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(issue.severity == "critical" ? Color.red :
issue.severity == "high" ? Color.orange :
issue.severity == "medium" ? Color.yellow : Color.blue)
.frame(width: 8, height: 8)
.padding(.top, 4)
VStack(alignment: .leading, spacing: 2) {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(issue.issueDescription)
.font(.callout)
}
}
.padding(.horizontal)
}
}
}
}
.padding(.vertical)
}
.navigationTitle(templateName)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Sync Status View
struct SyncStatusView: View {
@EnvironmentObject private var sync: SyncManager
@Environment(\.modelContext) private var context
@Query(
filter: #Predicate<LocalInspection> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalInspection.createdAt
) private var pendingInspections: [LocalInspection]
@Query(
filter: #Predicate<LocalIssue> { $0.syncStatus == "pending" || $0.syncStatus == "failed" },
sort: \LocalIssue.createdAt
) private var pendingIssues: [LocalIssue]
var body: some View {
List {
Section("Status") {
HStack {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
}
if let lastSync = sync.lastSyncAt {
LabeledContent("Last Sync",
value: lastSync.formatted(date: .abbreviated, time: .shortened))
}
if sync.isSyncing {
HStack {
ProgressView()
Text("Syncing…")
.foregroundStyle(.secondary)
}
}
if let error = sync.syncError {
Text(error).foregroundStyle(.red).font(.callout)
}
Button {
Task { await sync.triggerSync() }
} label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
}
if !pendingInspections.isEmpty {
Section("Pending Inspections (\(pendingInspections.count))") {
ForEach(pendingInspections) { inspection in
SyncRowView(
title: "Inspection",
status: inspection.syncStatus,
retryCount: inspection.syncRetryCount,
error: inspection.syncErrorMessage,
date: inspection.createdAt
)
}
}
}
if !pendingIssues.isEmpty {
Section("Pending Issues (\(pendingIssues.count))") {
ForEach(pendingIssues) { issue in
SyncRowView(
title: "\(issue.severity.capitalized) Issue",
status: issue.syncStatus,
retryCount: issue.syncRetryCount,
error: issue.syncErrorMessage,
date: issue.createdAt
)
}
}
}
if pendingInspections.isEmpty && pendingIssues.isEmpty && !sync.isSyncing {
Section {
Label("All items synced.", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
}
.navigationTitle("Pending Sync")
}
}
struct SyncRowView: View {
let title: String
let status: String
let retryCount: Int
let error: String?
let date: Date
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(title).font(.callout)
Spacer()
Text(status.capitalized)
.font(.caption2)
.foregroundStyle(status == "failed" ? .red : .orange)
}
Text(date.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
if let err = error {
Text(err)
.font(.caption2)
.foregroundStyle(.red)
.lineLimit(2)
}
if retryCount > 0 {
Text("Retried \(retryCount) time\(retryCount == 1 ? "" : "s")")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
// MARK: - Facilities, Templates, Settings (unchanged from Phase A)
struct FacilitiesListView: View {
@Query(sort: \LocalFacility.projectName) private var facilities: [LocalFacility]
var body: some View {
Group {
if facilities.isEmpty {
ContentUnavailableView(
"No Facilities",
systemImage: "building.2.slash",
description: Text("Connect to the internet to sync your assigned facilities.")
)
} else {
List(facilities) { facility in
VStack(alignment: .leading, spacing: 4) {
Text(facility.name).font(.headline)
if !facility.address.isEmpty {
Text(facility.address).font(.caption).foregroundStyle(.secondary)
}
if facility.projectName != "No Contract" {
Text(facility.projectName).font(.caption2).foregroundStyle(.blue)
}
Text("\(facility.areas.count) area\(facility.areas.count == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Facilities (\(facilities.count))")
}
}
struct TemplatesListView: View {
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
var body: some View {
Group {
if templates.isEmpty {
ContentUnavailableView(
"No Templates",
systemImage: "doc.text.magnifyingglass",
description: Text("Connect to the internet to sync inspection templates.")
)
} else {
List(templates) { template in
VStack(alignment: .leading, spacing: 4) {
Text(template.name).font(.headline)
if !template.templateDescription.isEmpty {
Text(template.templateDescription)
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
HStack {
if !template.frequency.isEmpty {
Label(template.frequencyLabel, systemImage: "clock")
.font(.caption2).foregroundStyle(.blue)
}
Spacer()
Text("\(template.formSchema.count) field\(template.formSchema.count == 1 ? "" : "s")")
.font(.caption2).foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
}
.navigationTitle("Templates (\(templates.count))")
}
}
struct SettingsView: View {
@EnvironmentObject private var auth: AuthManager
@EnvironmentObject private var sync: SyncManager
var body: some View {
List {
Section("Account") {
LabeledContent("Username", value: auth.currentUsername)
LabeledContent("Role", value: auth.currentUserRole.capitalized)
}
Section("Sync") {
Button { Task { await sync.triggerSync() } } label: {
Label("Sync Now", systemImage: "arrow.clockwise")
}
.disabled(!sync.isOnline || sync.isSyncing)
if let error = sync.syncError {
Text(error).font(.caption).foregroundStyle(.red)
}
}
Section {
Button(role: .destructive) {
Task { await auth.logout() }
} label: {
Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right")
}
}
Section("App Info") {
LabeledContent("Version", value: "Phase B")
LabeledContent("Server", value: Constants.baseURL)
}
}
.navigationTitle("Settings")
}
}
@@ -0,0 +1,282 @@
// 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.
import SwiftUI
import SwiftData
struct ExecuteInspectionView: View {
@Environment(\.modelContext) private var context
@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 = ""
// Auto-save timer
private let autoSaveInterval: TimeInterval = 30
private var template: LocalTemplate? {
// Look up the template from SwiftData
let id = inspection.templateServerId
return try? context.fetch(
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
).first
}
private var facility: LocalFacility? {
let id = inspection.facilityServerId
return try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first
}
private var formSchema: [[String: Any]] {
template?.formSchema ?? []
}
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)
}
}
.navigationTitle("Inspection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
ConnectivityBadge()
}
}
.onAppear {
// Load saved form data into local state
formValues = inspection.formData.compactMapValues { "\($0)" }
}
.onDisappear {
saveDraft(force: true)
}
// Auto-save every 30 seconds
.task {
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(autoSaveInterval))
saveDraft()
}
}
.sheet(isPresented: $showFlagIssue) {
FlagIssueView(inspection: inspection)
}
.alert("Submit Inspection", isPresented: $showSubmitAlert) {
Button("Submit", role: .none) { 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."))
}
}
// 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.lastModifiedAt = Date()
try? context.save()
if force {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isSaving = false
}
}
}
// Submit
private func submitInspection() {
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.status = "completed"
inspection.completedAt = Date()
inspection.syncStatus = "pending"
try? context.save()
// Trigger sync if online
if sync.isOnline {
Task { await sync.triggerSync() }
}
isSubmitting = false
}
// 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",
entityLocalId: inspection.localId,
fieldId: fid
)
inspection.pendingPhotos.append(photo)
context.insert(photo)
try? context.save()
}
// 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
}
}
// MARK: - Connectivity Badge
struct ConnectivityBadge: View {
@EnvironmentObject private var sync: SyncManager
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(sync.isOnline ? Color.green : Color.orange)
.frame(width: 8, height: 8)
Text(sync.isOnline ? "Online" : "Offline")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,163 @@
// Views/Inspection/FlagIssueView.swift
// -------------------------------------
// Sheet for flagging an issue during an inspection.
// Saves locally immediately; syncs to server when online.
import SwiftUI
import SwiftData
struct FlagIssueView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var sync: SyncManager
let inspection: LocalInspection
@State private var selectedAreaId: Int?
@State private var severity = "medium"
@State private var description = ""
@State private var selectedImage: UIImage?
@State private var photoLocalPath: String?
@State private var showImagePicker = false
private let severities = ["low", "medium", "high", "critical"]
private var areas: [LocalArea] {
let facilityId = inspection.facilityServerId
let results = try? context.fetch(
FetchDescriptor<LocalArea>(
predicate: #Predicate { $0.facilityServerId == facilityId },
sortBy: [SortDescriptor(\.name)]
)
)
return results ?? []
}
private var canSubmit: Bool {
selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
// Area
Section("Area") {
Picker("Area", selection: $selectedAreaId) {
Text("Select area…").tag(Optional<Int>(nil))
ForEach(areas) { area in
Text(area.name).tag(Optional(area.serverId))
}
}
.pickerStyle(.navigationLink)
}
// Severity
Section("Severity") {
Picker("Severity", selection: $severity) {
ForEach(severities, id: \.self) { s in
Text(s.capitalized).tag(s)
}
}
.pickerStyle(.segmented)
}
// Description
Section("Description") {
TextEditor(text: $description)
.frame(minHeight: 100)
}
// Photo
Section("Photo (Optional)") {
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 160)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button {
showImagePicker = true
} label: {
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
systemImage: "camera")
}
}
// Offline notice
if !sync.isOnline {
Section {
Label("You're offline — this issue will sync automatically.",
systemImage: "wifi.slash")
.font(.callout)
.foregroundStyle(.orange)
}
}
}
.navigationTitle("Flag Issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Submit") { submitIssue() }
.disabled(!canSubmit)
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(image: $selectedImage) { img in
savePhoto(img)
}
}
}
}
private func savePhoto(_ 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)
try? FileManager.default.createDirectory(at: photosDir,
withIntermediateDirectories: true)
let filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL)
photoLocalPath = fileURL.path
selectedImage = img
}
private func submitIssue() {
guard let areaId = selectedAreaId else { return }
let issue = LocalIssue(
inspectionLocalId: inspection.localId,
areaServerId: areaId,
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
issue.photoLocalPath = photoLocalPath
issue.inspection = inspection
inspection.localIssues.append(issue)
context.insert(issue)
// Create PendingPhoto if a photo was attached
if let path = photoLocalPath {
let photo = PendingPhoto(
localFilePath: path,
entityType: "issue",
entityLocalId: issue.localId
)
context.insert(photo)
}
try? context.save()
if sync.isOnline {
Task { await sync.triggerSync() }
}
dismiss()
}
}
@@ -0,0 +1,522 @@
// 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.
import SwiftUI
import PencilKit
// MARK: - FormFieldView
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
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) {
Text(label)
.font(.subheadline)
.fontWeight(.medium)
if required {
Text("*")
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
// Field input
fieldInput
}
.padding(.vertical, 2)
}
@ViewBuilder
private var fieldInput: some View {
switch fieldType {
case "section":
Text(label)
.font(.headline)
.foregroundStyle(.blue)
.padding(.top, 8)
case "label":
let textContent = field["text_content"] as? String
?? field["text"] as? String
?? label
Text(textContent)
.font(.callout)
.foregroundStyle(.secondary)
case "text":
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
case "textarea":
TextEditor(text: $value)
.frame(minHeight: 80)
.overlay(RoundedRectangle(cornerRadius: 6).stroke(Color(.systemGray4)))
case "number":
TextField(placeholder.isEmpty ? "0" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
case "email":
TextField(placeholder.isEmpty ? "email@example.com" : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
case "date":
DateFieldView(value: $value)
case "checkbox":
Toggle(label, isOn: Binding(
get: { value == "true" },
set: { value = $0 ? "true" : "false" }
))
case "checkbox_group":
CheckboxGroupView(field: field, value: $value)
case "radio":
RadioGroupView(field: field, value: $value)
case "select":
SelectFieldView(field: field, value: $value)
case "rating":
RatingFieldView(
maxRating: field["max"] as? Int ?? 5,
value: Binding(
get: { Int(value) ?? 0 },
set: { value = String($0) }
)
)
case "pass_fail":
PassFailFieldView(value: $value)
case "signature":
SignatureFieldView(value: $value)
case "image":
ImageFieldView(
fieldId: field["id"] as? String ?? UUID().uuidString,
currentValue: value,
onPhotoSelected: onPhotoSelected
)
case "table":
TableFieldView(field: field, value: $value)
default:
TextField(placeholder.isEmpty ? label : placeholder, text: $value)
.textFieldStyle(.roundedBorder)
}
}
}
// MARK: - DateFieldView
struct DateFieldView: View {
@Binding var value: String
private var dateBinding: Binding<Date> {
Binding(
get: {
let formatter = ISO8601DateFormatter()
return formatter.date(from: value) ?? Date()
},
set: {
let formatter = ISO8601DateFormatter()
value = formatter.string(from: $0)
}
)
}
var body: some View {
DatePicker("", selection: dateBinding, displayedComponents: .date)
.labelsHidden()
}
}
// MARK: - CheckboxGroupView
struct CheckboxGroupView: View {
let field: [String: Any]
@Binding var value: String // JSON array of selected values
private var options: [String] {
field["options"] as? [String] ?? []
}
private var selectedValues: Set<String> {
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),
let str = String(data: data, encoding: .utf8) {
value = str
}
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(options, id: \.self) { option in
Button {
toggle(option)
} label: {
HStack {
Image(systemName: selectedValues.contains(option)
? "checkmark.square.fill" : "square")
.foregroundStyle(selectedValues.contains(option) ? .blue : .secondary)
Text(option)
.foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - RadioGroupView
struct RadioGroupView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(options, id: \.self) { option in
Button {
value = option
} label: {
HStack {
Image(systemName: value == option
? "largecircle.fill.circle" : "circle")
.foregroundStyle(value == option ? .blue : .secondary)
Text(option)
.foregroundStyle(.primary)
}
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - SelectFieldView
struct SelectFieldView: View {
let field: [String: Any]
@Binding var value: String
private var options: [String] {
field["options"] as? [String] ?? []
}
var body: some View {
Picker("", selection: $value) {
Text("Select…").tag("")
ForEach(options, id: \.self) { option in
Text(option).tag(option)
}
}
.pickerStyle(.menu)
}
}
// MARK: - RatingFieldView
struct RatingFieldView: View {
let maxRating: Int
@Binding var value: Int
var body: some View {
HStack(spacing: 8) {
ForEach(1...maxRating, id: \.self) { star in
Button {
value = (value == star) ? 0 : star // tap same star to clear
} label: {
Image(systemName: star <= value ? "star.fill" : "star")
.font(.title2)
.foregroundStyle(star <= value ? .yellow : .secondary)
}
.buttonStyle(.plain)
}
if value > 0 {
Text("\(value)/\(maxRating)")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
}
}
}
}
// MARK: - PassFailFieldView
struct PassFailFieldView: View {
@Binding var value: String
var body: some View {
HStack(spacing: 12) {
Button {
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))
}
.buttonStyle(.plain)
Button {
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))
}
.buttonStyle(.plain)
}
}
}
// MARK: - SignatureFieldView
struct SignatureFieldView: UIViewRepresentable {
@Binding var value: String // stored as base64 PNG data URL
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.delegate = context.coordinator
return canvas
}
func updateUIView(_ canvas: PKCanvasView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(value: $value) }
class Coordinator: NSObject, PKCanvasViewDelegate {
var value: Binding<String>
init(value: Binding<String>) { self.value = value }
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
let image = canvasView.drawing.image(from: canvasView.bounds, scale: 1)
if let data = image.pngData() {
value.wrappedValue = "data:image/png;base64,\(data.base64EncodedString())"
}
}
}
}
// MARK: - ImageFieldView
struct ImageFieldView: View {
let fieldId: String
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var showPicker = false
@State private var selectedImage: UIImage?
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Preview
if let img = selectedImage {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if currentValue.hasPrefix("uploads/") {
// Already uploaded in a previous session show a placeholder
HStack {
Image(systemName: "photo.fill")
.foregroundStyle(.secondary)
Text("Photo attached")
.font(.callout)
.foregroundStyle(.secondary)
}
}
Button {
showPicker = true
} label: {
Label(
selectedImage != nil || currentValue.hasPrefix("uploads/")
? "Replace Photo" : "Attach Photo",
systemImage: "camera"
)
}
.buttonStyle(.bordered)
}
.sheet(isPresented: $showPicker) {
ImagePickerView(image: $selectedImage) { img in
saveAndCallback(img)
}
}
}
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)
try? FileManager.default.createDirectory(at: photosDir,
withIntermediateDirectories: true)
let filename = "\(UUID().uuidString).jpg"
let fileURL = photosDir.appendingPathComponent(filename)
try? data.write(to: fileURL)
selectedImage = img
onPhotoSelected?(fileURL.path)
}
}
// MARK: - ImagePickerView
struct ImagePickerView: UIViewControllerRepresentable {
@Binding var image: UIImage?
var onSelected: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
return picker
}
func updateUIViewController(_ vc: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePickerView
init(_ parent: ImagePickerView) { self.parent = parent }
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
if let img = info[.originalImage] as? UIImage {
parent.image = img
parent.onSelected(img)
}
picker.dismiss(animated: true)
}
}
}
// MARK: - TableFieldView
struct TableFieldView: View {
let field: [String: Any]
@Binding var value: String // JSON: [[String: String]]
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
}
}
private func updateCell(row: Int, col: String, newValue: String) {
var table = tableData
while table.count <= row {
table.append(Dictionary(uniqueKeysWithValues: columns.map { ($0, "") }))
}
table[row][col] = newValue
if let data = try? JSONSerialization.data(withJSONObject: table),
let str = String(data: data, encoding: .utf8) {
value = str
}
}
var body: some View {
ScrollView(.horizontal) {
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 4) {
// Header row
GridRow {
ForEach(columns, id: \.self) { col in
Text(col)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
}
Divider()
// Data rows
ForEach(0..<rowCount, id: \.self) { rowIdx in
GridRow {
ForEach(columns, id: \.self) { col in
let cellValue = tableData.indices.contains(rowIdx)
? tableData[rowIdx][col] ?? "" : ""
TextField("", text: Binding(
get: { cellValue },
set: { updateCell(row: rowIdx, col: col, newValue: $0) }
))
.textFieldStyle(.roundedBorder)
.frame(minWidth: 100)
}
}
}
}
.padding(4)
}
}
}
@@ -0,0 +1,147 @@
// Views/Inspection/StartInspectionView.swift
// ------------------------------------------
// Screen where the inspector chooses a template, facility, and optional area
// before starting a new inspection. Creates the LocalInspection record
// immediately so the form can be resumed if the app is backgrounded.
import SwiftUI
import SwiftData
struct StartInspectionView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Query(sort: \LocalTemplate.name) private var templates: [LocalTemplate]
@Query(sort: \LocalFacility.name) private var facilities: [LocalFacility]
@State private var selectedTemplateId: Int?
@State private var selectedFacilityId: Int?
@State private var selectedAreaId: Int?
@State private var navigateToExecution = false
@State private var createdInspection: LocalInspection?
@EnvironmentObject private var auth: AuthManager
private var selectedFacility: LocalFacility? {
facilities.first { $0.serverId == selectedFacilityId }
}
private var areas: [LocalArea] {
selectedFacility?.areas.sorted { $0.name < $1.name } ?? []
}
private var canStart: Bool {
selectedTemplateId != nil && selectedFacilityId != nil
}
var body: some View {
NavigationStack {
Form {
// Template picker
Section("Inspection Template") {
if templates.isEmpty {
Text("No templates available. Sync required.")
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Template", selection: $selectedTemplateId) {
Text("Select a template…").tag(Optional<Int>(nil))
ForEach(templates) { template in
VStack(alignment: .leading) {
Text(template.name)
if !template.frequency.isEmpty {
Text(template.frequencyLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tag(Optional(template.serverId))
}
}
.pickerStyle(.navigationLink)
}
}
// Facility picker
Section("Facility") {
if facilities.isEmpty {
Text("No facilities available. Sync required.")
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker("Facility", selection: $selectedFacilityId) {
Text("Select a facility…").tag(Optional<Int>(nil))
ForEach(facilities) { facility in
Text(facility.name).tag(Optional(facility.serverId))
}
}
.pickerStyle(.navigationLink)
.onChange(of: selectedFacilityId) {
selectedAreaId = nil // reset area when facility changes
}
}
}
// Area picker (optional)
if selectedFacilityId != nil {
Section("Area (Optional)") {
Picker("Area", selection: $selectedAreaId) {
Text("No specific area").tag(Optional<Int>(nil))
ForEach(areas) { area in
Text(area.name).tag(Optional(area.serverId))
}
}
.pickerStyle(.navigationLink)
.disabled(areas.isEmpty)
}
}
// Start button
Section {
Button {
startInspection()
} label: {
HStack {
Spacer()
Label("Start Inspection", systemImage: "play.circle.fill")
.font(.headline)
Spacer()
}
}
.disabled(!canStart)
}
}
.navigationTitle("New Inspection")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.navigationDestination(isPresented: $navigateToExecution) {
if let inspection = createdInspection {
ExecuteInspectionView(inspection: inspection)
}
}
}
}
private func startInspection() {
guard let templateId = selectedTemplateId,
let facilityId = selectedFacilityId
else { return }
let inspection = LocalInspection(
templateServerId: templateId,
facilityServerId: facilityId,
areaServerId: selectedAreaId,
inspectorUserId: auth.currentUserId
)
context.insert(inspection)
try? context.save()
createdInspection = inspection
navigateToExecution = true
}
}