05/04 Update the app functionalities

This commit is contained in:
Nguyen Ngo
2026-05-04 17:36:55 -04:00
parent 09715e9c11
commit 9d6b5e5bcb
7 changed files with 980 additions and 131 deletions
+256 -40
View File
@@ -2,11 +2,29 @@
// ------------------------------------
// Phase C: adds Inspection History tab, polished Settings with cache clear,
// and schedules background sync on scene enter background.
//
// CHANGED (sidebar update):
// - Templates removed from sidebar entirely.
// - Issues view added for all roles.
// - History moved to sit between Pending Sync and Settings.
// - Tab identity is now enum-based (SidebarTab) instead of raw Int
// so adding/removing tabs never breaks the detail switch.
import SwiftUI
import SwiftData
import Combine
// MARK: - SidebarTab
enum SidebarTab: Hashable {
case myInspections
case issues // inspector role only
case facilities
case pendingSync
case history // moved after Pending Sync
case settings
}
struct DashboardView: View {
@EnvironmentObject private var auth: AuthManager
@@ -20,17 +38,17 @@ struct DashboardView: View {
order: .reverse
) private var myInspections: [LocalInspection]
@State private var selectedTab = 0
@State private var selectedTab: SidebarTab = .myInspections
@State private var showNewInspection = false
var body: some View {
NavigationSplitView {
List {
// My Inspections
Button { selectedTab = 0 } label: {
// My Inspections
Button { selectedTab = .myInspections } label: {
HStack {
Label("My Inspections", systemImage: "checklist")
.foregroundStyle(selectedTab == 0 ? .blue : .primary)
.foregroundStyle(selectedTab == .myInspections ? .blue : .primary)
Spacer()
if !myInspections.isEmpty {
Text("\(myInspections.count)")
@@ -41,34 +59,27 @@ struct DashboardView: View {
}
}
}
.listRowBackground(selectedTab == 0 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .myInspections ? Color.blue.opacity(0.1) : Color.clear)
// History
Button { selectedTab = 1 } label: {
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == 1 ? .blue : .primary)
// Issues (all roles)
Button { selectedTab = .issues } label: {
Label("Issues", systemImage: "exclamationmark.triangle")
.foregroundStyle(selectedTab == .issues ? .blue : .primary)
}
.listRowBackground(selectedTab == 1 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .issues ? Color.blue.opacity(0.1) : Color.clear)
// Facilities
Button { selectedTab = 2 } label: {
// Facilities
Button { selectedTab = .facilities } label: {
Label("Facilities", systemImage: "building.2")
.foregroundStyle(selectedTab == 2 ? .blue : .primary)
.foregroundStyle(selectedTab == .facilities ? .blue : .primary)
}
.listRowBackground(selectedTab == 2 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .facilities ? Color.blue.opacity(0.1) : Color.clear)
// Templates
Button { selectedTab = 3 } label: {
Label("Templates", systemImage: "doc.text")
.foregroundStyle(selectedTab == 3 ? .blue : .primary)
}
.listRowBackground(selectedTab == 3 ? Color.blue.opacity(0.1) : Color.clear)
// Pending Sync
Button { selectedTab = 4 } label: {
// Pending Sync
Button { selectedTab = .pendingSync } label: {
HStack {
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
.foregroundStyle(selectedTab == 4 ? .blue : .primary)
.foregroundStyle(selectedTab == .pendingSync ? .blue : .primary)
Spacer()
if sync.pendingCount > 0 {
Text("\(sync.pendingCount)")
@@ -80,14 +91,21 @@ struct DashboardView: View {
}
}
}
.listRowBackground(selectedTab == 4 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .pendingSync ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectedTab = 5 } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == 5 ? .blue : .primary)
// History (moved sits between Pending Sync and Settings)
Button { selectedTab = .history } label: {
Label("History", systemImage: "clock.arrow.circlepath")
.foregroundStyle(selectedTab == .history ? .blue : .primary)
}
.listRowBackground(selectedTab == 5 ? Color.blue.opacity(0.1) : Color.clear)
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
// Settings
Button { selectedTab = .settings } label: {
Label("Settings", systemImage: "gear")
.foregroundStyle(selectedTab == .settings ? .blue : .primary)
}
.listRowBackground(selectedTab == .settings ? Color.blue.opacity(0.1) : Color.clear)
}
.navigationTitle("JQC Inspector")
.listStyle(.sidebar)
@@ -101,17 +119,13 @@ struct DashboardView: View {
.safeAreaInset(edge: .bottom) { syncStatusFooter }
} detail: {
// CHANGED: each tab is wrapped in its own NavigationStack.
// Without this, NavigationLink pushes from MyInspectionsView accumulate
// on a shared implicit stack switching sidebar tabs does not clear
// the pushed ExecuteInspectionView, leaving the form stuck on screen.
switch selectedTab {
case 0: NavigationStack { MyInspectionsView() }
case 1: NavigationStack { InspectionHistoryView() }
case 2: NavigationStack { FacilitiesListView() }
case 3: NavigationStack { TemplatesListView() }
case 4: NavigationStack { SyncStatusView() }
default: NavigationStack { SettingsView() }
case .myInspections: NavigationStack { MyInspectionsView() }
case .issues: NavigationStack { IssuesListView() }
case .facilities: NavigationStack { FacilitiesListView() }
case .pendingSync: NavigationStack { SyncStatusView() }
case .history: NavigationStack { InspectionHistoryView() }
case .settings: NavigationStack { SettingsView() }
}
}
.sheet(isPresented: $showNewInspection) {
@@ -169,6 +183,10 @@ struct MyInspectionsView: View {
@Environment(\.modelContext) private var context
// Deletion confirmation state
@State private var pendingDelete: LocalInspection?
@State private var showDeleteAlert = false
var body: some View {
Group {
if inspections.isEmpty {
@@ -188,10 +206,55 @@ struct MyInspectionsView: View {
} label: {
InspectionRowView(inspection: inspection, context: context)
}
// Only drafts may be deleted submitted/pending-sync inspections are kept
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if inspection.status == "draft" {
Button(role: .destructive) {
pendingDelete = inspection
showDeleteAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
}
}
.navigationTitle("My Inspections")
// Confirmation before deletion destructive action cannot be undone
.alert("Delete Draft?", isPresented: $showDeleteAlert, presenting: pendingDelete) { inspection in
Button("Delete", role: .destructive) { deleteDraft(inspection) }
Button("Cancel", role: .cancel) { pendingDelete = nil }
} message: { inspection in
Text("\"\(draftName(inspection))\" will be permanently removed from this device. This cannot be undone.")
}
}
private func draftName(_ inspection: LocalInspection) -> String {
let templateId = inspection.templateServerId // plain Int safe to capture in #Predicate
return (try? context.fetch(
FetchDescriptor<LocalTemplate>(
predicate: #Predicate { $0.serverId == templateId }
)
).first?.name) ?? "this inspection"
}
private func deleteDraft(_ inspection: LocalInspection) {
// Delete associated pending photos from disk and SwiftData
for photo in inspection.pendingPhotos {
try? FileManager.default.removeItem(atPath: photo.localFilePath)
context.delete(photo)
}
// Delete associated local issues
for issue in inspection.localIssues {
if let path = issue.photoLocalPath {
try? FileManager.default.removeItem(atPath: path)
}
context.delete(issue)
}
context.delete(inspection)
try? context.save()
pendingDelete = nil
}
}
@@ -480,6 +543,159 @@ struct FacilitiesListView: View {
}
}
// MARK: - Issues (Inspector)
// Shows issues flagged by this inspector across all inspections.
// Read-only list tapping shows description and sync status detail.
struct IssuesListView: View {
@Query(
sort: \LocalIssue.createdAt,
order: .reverse
) private var issues: [LocalIssue]
@Environment(\.modelContext) private var context
var body: some View {
Group {
if issues.isEmpty {
ContentUnavailableView(
"No Issues",
systemImage: "exclamationmark.triangle",
description: Text("Issues you flag during inspections will appear here.")
)
} else {
List(issues) { issue in
NavigationLink {
IssueDetailView(issue: issue)
} label: {
IssueRowView(issue: issue, context: context)
}
}
}
}
.navigationTitle("Issues (\(issues.count))")
}
}
struct IssueRowView: View {
let issue: LocalIssue
let context: ModelContext
private var facilityName: String {
let id = issue.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var severityColor: Color {
switch issue.severity {
case "critical": return .red
case "high": return .orange
case "medium": return .yellow
default: return .blue
}
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(severityColor)
.frame(width: 10, height: 10)
.padding(.top, 5)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(issue.severity.capitalized)
.font(.caption.bold())
.foregroundStyle(severityColor)
Spacer()
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
}
Text(issue.issueDescription)
.font(.callout)
.lineLimit(2)
Text(facilityName)
.font(.caption)
.foregroundStyle(.secondary)
Text(issue.createdAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
struct IssueDetailView: View {
let issue: LocalIssue
@Environment(\.modelContext) private var context
private var facilityName: String {
let id = issue.facilityServerId
return (try? context.fetch(
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
).first?.name) ?? "Unknown Facility"
}
private var severityColor: Color {
switch issue.severity {
case "critical": return .red
case "high": return .orange
case "medium": return .yellow
default: return .blue
}
}
var body: some View {
List {
Section("Issue Details") {
LabeledContent("Severity") {
Text(issue.severity.capitalized)
.foregroundStyle(severityColor)
.fontWeight(.semibold)
}
LabeledContent("Facility", value: facilityName)
LabeledContent("Reported", value: issue.createdAt.formatted(
date: .long, time: .shortened))
}
Section("Description") {
Text(issue.issueDescription)
.font(.callout)
}
Section("Sync Status") {
LabeledContent("Status") {
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
}
if let err = issue.syncErrorMessage {
Text(err).font(.caption).foregroundStyle(.red)
}
if issue.syncRetryCount > 0 {
LabeledContent("Retry Count", value: "\(issue.syncRetryCount)")
}
}
if let photoPath = issue.photoLocalPath {
Section("Photo") {
if let img = UIImage(contentsOfFile: photoPath) {
Image(uiImage: img)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Label("Photo pending upload", systemImage: "photo")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Issue Detail")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Templates
struct TemplatesListView: View {
@@ -67,7 +67,7 @@ struct ExecuteInspectionView: View {
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.background(Color(.systemGroupedBackground))
.background(Color(.systemBackground))
.navigationTitle(template?.name ?? "Inspection")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -140,10 +140,6 @@ struct ExecuteInspectionView: View {
.padding(.bottom, 16)
}
// Inspector notes card
notesCard
.padding(.bottom, 16)
// Action buttons
actionButtons
.padding(.bottom, 32)
@@ -395,10 +391,21 @@ struct ExecuteInspectionView: View {
}
}
// MARK: - WidthPreferenceKey
// Used by GridFormView to read its container width reliably on any device
// orientation, split-screen size change, or rotation without GeometryReader's
// ScrollView height ambiguity.
private struct WidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
// MARK: - GridFormView
// CHANGED: new view renders the form schema using the same 12-column grid
// layout as the web app's .form-grid CSS grid. Each field is positioned using
// its col/row/colSpan/rowSpan attributes from the JSON schema.
// Renders the form schema using the same 12-column grid as the web app's
// .form-grid CSS grid. Each field is positioned using col/row/colSpan/rowSpan.
struct GridFormView: View {
@@ -407,38 +414,58 @@ struct GridFormView: View {
var onPhotoSelected: ((String, [String: Any]) -> Void)?
var onFieldChanged: (() -> Void)?
// Grid constants match the web app
static let totalColumns: Int = 12
static let cellGap: CGFloat = 4 // column gap (web: 4px)
static let rowGap: CGFloat = 4 // row gap (web: 4px)
static let cellAspect: CGFloat = 52/72 // cellH / cellW (web: 52px / 72px)
static let cardPadding: CGFloat = 16 // card inset on all sides
// Grid constants kept in sync with the web form editor
// Web editor JS: COLS=12 CELL_W=72 CELL_H=52 GAP=8 (col gap = 8px)
// Web CSS execute: gap: 4px 8px (row-gap=4px, col-gap=8px)
static let totalColumns: Int = 12
static let cellGap: CGFloat = 8 // column gap matches editor GAP=8 and CSS col-gap
static let rowGap: CGFloat = 4 // row gap matches CSS row-gap
static let cellAspect: CGFloat = 52/72 // cellH / cellW matches editor CELL_H/CELL_W
static let cardPadding: CGFloat = 16 // card inset on all sides
// @State to capture the rendered grid width from the background GeometryReader.
// Starts at a reasonable iPad default (952 = 1000 max-width 2×24 outer padding).
@State private var gridWidth: CGFloat = 952
// Minimum cell height (points) per field type ensures 44pt touch targets
// on iPad even when the template author assigned a very short rowSpan.
static let minCellH: [String: CGFloat] = [
"pass_fail": 44,
"rating": 36,
"checkbox": 36,
"checkbox_group": 44,
"radio": 44,
"select": 36,
"date": 36,
"image": 60,
"signature": 80,
"table": 80,
]
// Width captured via PreferenceKey updates on rotation & split-screen.
// Default 952 = 1000 max-width 2×24 outer padding (safe iPad landscape floor).
@State private var containerWidth: CGFloat = 952
var body: some View {
// Use a zero-height background reader so the ScrollView sees the correct
// intrinsic height of the ZStack, not GeometryReader's proposed size.
ZStack(alignment: .topLeading) {
// Card background
// Card background
RoundedRectangle(cornerRadius: 12)
.fill(Color(.secondarySystemGroupedBackground))
.fill(Color(.secondarySystemBackground))
// Width probe invisible, sits behind the grid, reads available width
// Width probe zero-size overlay, reports container width
// Using a background Color.clear with a GeometryReader that sends
// its width via PreferenceKey is the idiomatic SwiftUI pattern that
// works correctly inside ScrollView on all iOS versions.
Color.clear
.frame(height: 1)
.frame(maxWidth: .infinity)
.frame(height: 0)
.background(
GeometryReader { geo in
Color.clear.onAppear { gridWidth = max(geo.size.width, 100) }
Color.clear.preference(
key: WidthPreferenceKey.self,
value: geo.size.width
)
}
)
// Field overlays positioned using live gridWidth
let cellW = (gridWidth - 2 * Self.cardPadding
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
/ CGFloat(Self.totalColumns)
// Field overlays
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
ForEach(schema.indices, id: \.self) { idx in
@@ -449,17 +476,26 @@ struct GridFormView: View {
}
}
}
// Explicit height derived from the same cellW/cellH arithmetic
// this is what the ScrollView measures, so it can never be wrong.
.onPreferenceChange(WidthPreferenceKey.self) { width in
if width > 0 { containerWidth = width }
}
// Height is always derived from the same arithmetic as cell offsets
// the ScrollView measures this frame and can never be wrong.
.frame(height: canvasHeight() + 2 * Self.cardPadding)
}
// Derived cell width from current containerWidth
private var computedCellW: CGFloat {
(containerWidth - 2 * Self.cardPadding
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
/ CGFloat(Self.totalColumns)
}
// Canvas height
private func canvasHeight() -> CGFloat {
let cellW = (gridWidth - 2 * Self.cardPadding
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
/ CGFloat(Self.totalColumns)
let cellW = computedCellW
let cellH = cellW * Self.cellAspect
let maxRow = schema.reduce(0) { acc, f in
let r = f["row"] as? Int ?? 1
@@ -482,7 +518,12 @@ struct GridFormView: View {
let yOffset = CGFloat(row - 1) * (cellH + Self.rowGap) + Self.cardPadding
let width = CGFloat(colSpan) * cellW + CGFloat(colSpan - 1) * Self.cellGap
let height = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
// Apply per-type minimum height so touch targets are always reachable.
let ftype = field["type"] as? String ?? "text"
let rawHeight = CGFloat(rowSpan) * cellH + CGFloat(rowSpan - 1) * Self.rowGap
let minH = Self.minCellH[ftype] ?? 0
let height = max(rawHeight, minH)
let fid = fieldId(field)
@@ -544,9 +585,11 @@ struct GridCellContentView: View {
}
}
// Input widget fills remaining cell height
// Input widget sized naturally, not stretched to fill cell
// maxHeight:.infinity caused a large gap between the label and the
// input widget when the cell was taller than the content needed.
fieldInput
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .topLeading)
// Help text matches .help-text
if !helpText.isEmpty {
@@ -556,7 +599,9 @@ struct GridCellContentView: View {
.lineLimit(2)
}
}
.clipped()
// No .clipped() overflow is intentionally visible so tall content
// (dropdowns, multi-line labels) is never silently truncated.
// Matches the web form's .fg-cell { overflow: visible } rule.
}
@ViewBuilder
@@ -661,8 +706,9 @@ struct GridCellContentView: View {
)
// Image / Photo upload
// Uses a compact inline zone to match the web's .upload-zone dashed style.
case "image":
ImageFieldView(
CompactImageFieldView(
fieldId: field["id"] as? String ?? UUID().uuidString,
currentValue: value,
onPhotoSelected: onPhotoSelected
@@ -698,6 +744,94 @@ struct GridCellContentView: View {
}
}
// MARK: - CompactImageFieldView
// Grid-cell-sized photo upload zone mirrors the web's .upload-zone style:
// dashed border, small icon + text, filename shown inline when a photo is chosen.
// CHANGED: replaces the full-size ImageFieldView inside grid cells to fix the
// oversized "Attach Photo" button that was too large for compact grid cells.
struct CompactImageFieldView: View {
let fieldId: String
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var selectedImage: UIImage?
@State private var chosenName: String = ""
@State private var showChoice = false
@State private var showCamera = false
@State private var showLibrary = false
private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
var hasPhoto: Bool { selectedImage != nil || currentValue.hasPrefix("uploads/") || currentValue.hasPrefix("local://") }
var body: some View {
Button {
if cameraAvailable { showChoice = true } else { showLibrary = true }
} label: {
HStack(spacing: 6) {
Image(systemName: hasPhoto ? "photo.fill" : "camera")
.font(.system(size: 13))
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
VStack(alignment: .leading, spacing: 1) {
Text(hasPhoto ? (chosenName.isEmpty ? "Photo attached" : chosenName)
: "Upload photo")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(hasPhoto ? .blue : Color(.secondaryLabel))
.lineLimit(1)
.truncationMode(.middle)
if !hasPhoto {
Text("Tap to choose")
.font(.system(size: 10))
.foregroundStyle(Color(.tertiaryLabel))
}
}
Spacer()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, minHeight: 44)
.background(hasPhoto ? Color.blue.opacity(0.07) : Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
hasPhoto ? Color.blue.opacity(0.4) : Color(.systemGray4),
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])
)
)
}
.buttonStyle(.plain)
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
Button("Take Photo") { showCamera = true }
Button("Photo Library") { showLibrary = true }
Button("Cancel", role: .cancel) {}
}
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: $selectedImage, onSelected: saveAndCallback)
.ignoresSafeArea()
}
.sheet(isPresented: $showLibrary) {
LibraryPickerView(image: $selectedImage, onSelected: saveAndCallback)
}
}
private func saveAndCallback(_ img: UIImage) {
guard let data = img.jpegData(compressionQuality: 0.8) else { return }
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let photosDir = docs.appendingPathComponent("JQC/Photos", isDirectory: true)
try? FileManager.default.createDirectory(at: photosDir, withIntermediateDirectories: true)
let filename = "\(UUID().uuidString).jpg"
let url = photosDir.appendingPathComponent(filename)
try? data.write(to: url)
selectedImage = img
chosenName = filename
onPhotoSelected?(url.path)
}
}
// MARK: - CellDatePicker
// Compact date picker for a grid cell shows a short date format.
@@ -1,7 +1,12 @@
// Views/Inspection/FlagIssueView.swift
// -------------------------------------
// Views/Dashboard/FlagIssueView.swift
// ------------------------------------
// Sheet for flagging an issue during an inspection.
// Saves locally immediately; syncs to server when online.
//
// CHANGED: Area picker removed. Facility is derived directly from the
// inspection (inspection.facilityServerId) and displayed as read-only info,
// matching the web app's flag_issue.html behaviour where facility_id is
// a hidden field populated from the inspection context.
import SwiftUI
import SwiftData
@@ -14,42 +19,55 @@ struct FlagIssueView: View {
let inspection: LocalInspection
@State private var selectedAreaId: Int?
@State private var severity = "medium"
@State private var description = ""
@State private var severity = "medium"
@State private var description = ""
@State private var selectedImage: UIImage?
@State private var photoLocalPath: String?
@State private var showImagePicker = false
@State private var showChoice = false
@State private var showCamera = false
@State private var showLibrary = false
private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
private let severities = ["low", "medium", "high", "critical"]
private var areas: [LocalArea] {
let facilityId = inspection.facilityServerId
let results = try? context.fetch(
FetchDescriptor<LocalArea>(
predicate: #Predicate { $0.facilityServerId == facilityId },
sortBy: [SortDescriptor(\.name)]
private var facility: LocalFacility? {
let id = inspection.facilityServerId
return try? context.fetch(
FetchDescriptor<LocalFacility>(
predicate: #Predicate { $0.serverId == id }
)
)
return results ?? []
).first
}
private var canSubmit: Bool {
selectedAreaId != nil && !description.trimmingCharacters(in: .whitespaces).isEmpty
!description.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
// Area
Section("Area") {
Picker("Area", selection: $selectedAreaId) {
Text("Select area…").tag(Optional<Int>(nil))
ForEach(areas) { area in
Text(area.name).tag(Optional(area.serverId))
// Facility (read-only) matches web alert banner
Section {
HStack(spacing: 10) {
Image(systemName: "building.2")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
Text("Facility")
.font(.caption)
.foregroundStyle(.secondary)
Text(facility?.name ?? "")
.font(.body)
}
}
.pickerStyle(.navigationLink)
.padding(.vertical, 2)
} header: {
Text("Inspection Context")
} footer: {
Text("Issue will be logged against this facility.")
.font(.caption)
}
// Severity
@@ -78,7 +96,7 @@ struct FlagIssueView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button {
showImagePicker = true
if cameraAvailable { showChoice = true } else { showLibrary = true }
} label: {
Label(selectedImage == nil ? "Attach Photo" : "Replace Photo",
systemImage: "camera")
@@ -107,10 +125,17 @@ struct FlagIssueView: View {
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showImagePicker) {
ImagePickerView(image: $selectedImage) { img in
savePhoto(img)
}
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
Button("Take Photo") { showCamera = true }
Button("Photo Library") { showLibrary = true }
Button("Cancel", role: .cancel) {}
}
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: $selectedImage, onSelected: savePhoto)
.ignoresSafeArea()
}
.sheet(isPresented: $showLibrary) {
LibraryPickerView(image: $selectedImage, onSelected: savePhoto)
}
}
}
@@ -129,11 +154,9 @@ struct FlagIssueView: View {
}
private func submitIssue() {
guard let areaId = selectedAreaId else { return }
let issue = LocalIssue(
inspectionLocalId: inspection.localId,
areaServerId: areaId,
facilityServerId: inspection.facilityServerId,
severity: severity,
description: description.trimmingCharacters(in: .whitespaces)
)
@@ -142,7 +165,6 @@ struct FlagIssueView: View {
inspection.localIssues.append(issue)
context.insert(issue)
// Create PendingPhoto if a photo was attached
if let path = photoLocalPath {
let photo = PendingPhoto(
localFilePath: path,
@@ -12,6 +12,7 @@
import SwiftUI
import PencilKit
import PhotosUI
// MARK: - FormFieldView
// Retained for standalone/legacy usage outside the grid inspection form.
@@ -420,8 +421,14 @@ struct ImageFieldView: View {
let currentValue: String
var onPhotoSelected: ((String) -> Void)?
@State private var showPicker = false
@State private var selectedImage: UIImage?
@State private var showChoice = false
@State private var showCamera = false
@State private var showLibrary = false
private var cameraAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -445,7 +452,7 @@ struct ImageFieldView: View {
}
Button {
showPicker = true
if cameraAvailable { showChoice = true } else { showLibrary = true }
} label: {
Label(
selectedImage != nil || currentValue.hasPrefix("uploads/")
@@ -459,10 +466,17 @@ struct ImageFieldView: View {
}
.buttonStyle(.plain)
}
.sheet(isPresented: $showPicker) {
ImagePickerView(image: $selectedImage) { img in
saveAndCallback(img)
}
.confirmationDialog("Add Photo", isPresented: $showChoice, titleVisibility: .visible) {
Button("Take Photo") { showCamera = true }
Button("Photo Library") { showLibrary = true }
Button("Cancel", role: .cancel) {}
}
.fullScreenCover(isPresented: $showCamera) {
CameraPickerView(image: $selectedImage, onSelected: saveAndCallback)
.ignoresSafeArea()
}
.sheet(isPresented: $showLibrary) {
LibraryPickerView(image: $selectedImage, onSelected: saveAndCallback)
}
}
@@ -480,16 +494,24 @@ struct ImageFieldView: View {
}
// MARK: - ImagePickerView
// Retained as a thin typealias so existing call sites that reference
// ImagePickerView(image:onSelected:) continue to compile without changes.
// Internally it now just shows the library picker directly callers that
// need the camera+library choice should use the inline pattern in ImageFieldView.
// NOTE: FlagIssueView and CompactImageFieldView have been updated to use
// the inline confirmationDialog pattern instead.
typealias ImagePickerView = LibraryPickerView
struct ImagePickerView: UIViewControllerRepresentable {
// Camera UIImagePickerController with .camera source
struct CameraPickerView: UIViewControllerRepresentable {
@Binding var image: UIImage?
var onSelected: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = context.coordinator
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
return picker
}
@@ -497,8 +519,8 @@ struct ImagePickerView: UIViewControllerRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePickerView
init(_ parent: ImagePickerView) { self.parent = parent }
let parent: CameraPickerView
init(_ parent: CameraPickerView) { self.parent = parent }
func imagePickerController(
_ picker: UIImagePickerController,
@@ -517,6 +539,45 @@ struct ImagePickerView: UIViewControllerRepresentable {
}
}
// Photo Library PHPickerViewController (no permission required)
struct LibraryPickerView: UIViewControllerRepresentable {
@Binding var image: UIImage?
var onSelected: (UIImage) -> Void
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ vc: PHPickerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: LibraryPickerView
init(_ parent: LibraryPickerView) { self.parent = parent }
func picker(_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) else { return }
provider.loadObject(ofClass: UIImage.self) { object, _ in
DispatchQueue.main.async {
if let img = object as? UIImage {
self.parent.image = img
self.parent.onSelected(img)
}
}
}
}
}
}
// MARK: - TableFieldView
struct TableFieldView: View {