05/04 Update the app functionalities
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user