05/02 Phase B
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user