05/04 Update the app functionalities
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
// Only available when online. Displays score, facility, template, and date.
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct InspectionHistoryView: View {
|
||||
|
||||
@@ -41,7 +42,11 @@ struct InspectionHistoryView: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(inspections) { inspection in
|
||||
HistoryRowView(inspection: inspection)
|
||||
NavigationLink {
|
||||
HistoryDetailView(inspection: inspection)
|
||||
} label: {
|
||||
HistoryRowView(inspection: inspection)
|
||||
}
|
||||
}
|
||||
|
||||
// Load more
|
||||
@@ -181,3 +186,408 @@ struct HistoryRowView: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - History Detail View
|
||||
// Shows submitted inspection details.
|
||||
// For inspections originally submitted from this device (matched via mobileLocalId),
|
||||
// the filled-in form responses are shown using the same grid as ExecuteInspectionView.
|
||||
// For inspections submitted elsewhere, only summary fields are shown.
|
||||
|
||||
struct HistoryDetailView: View {
|
||||
|
||||
let inspection: APIInspectionSummary
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
|
||||
// Look up the local copy by mobileLocalId — present only for this-device submissions
|
||||
private var localCopy: LocalInspection? {
|
||||
guard let lid = inspection.mobileLocalId else { return nil }
|
||||
return try? context.fetch(
|
||||
FetchDescriptor<LocalInspection>(
|
||||
predicate: #Predicate { $0.localId == lid }
|
||||
)
|
||||
).first
|
||||
}
|
||||
|
||||
// Fetch the template schema so we can render the form grid
|
||||
private var localTemplate: LocalTemplate? {
|
||||
guard let copy = localCopy else { return nil }
|
||||
let id = copy.templateServerId
|
||||
return try? context.fetch(
|
||||
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
||||
).first
|
||||
}
|
||||
|
||||
private var formSchema: [[String: Any]] { localTemplate?.formSchema ?? [] }
|
||||
|
||||
// Convert saved form data to [String: String] for the grid renderer
|
||||
private var savedValues: [String: String] {
|
||||
guard let copy = localCopy else { return [:] }
|
||||
return copy.formData.compactMapValues { "\($0)" }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
|
||||
// ── Summary card ───────────────────────────────────────────
|
||||
summaryCard
|
||||
|
||||
// ── Flagged issues ─────────────────────────────────────────
|
||||
if let copy = localCopy, !copy.localIssues.isEmpty {
|
||||
issuesCard(copy.localIssues)
|
||||
}
|
||||
|
||||
// ── Form responses ─────────────────────────────────────────
|
||||
if !formSchema.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Form Responses")
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
// Read-only form grid — reuses GridFormView with disabled inputs
|
||||
ReadOnlyGridFormView(
|
||||
schema: formSchema,
|
||||
formValues: savedValues
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
} else if localCopy != nil {
|
||||
// Template schema no longer cached locally
|
||||
infoRow(
|
||||
icon: "doc.text",
|
||||
text: "Form schema not available offline. Sync to view full responses."
|
||||
)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.navigationTitle(inspection.templateName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// ── Summary card ───────────────────────────────────────────────────────
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
|
||||
// Score
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
infoRow(icon: "building.2", text: inspection.facilityName)
|
||||
if let area = inspection.areaName {
|
||||
infoRow(icon: "mappin", text: area)
|
||||
}
|
||||
if let date = inspection.inspectionDateParsed {
|
||||
infoRow(icon: "calendar", text: date.formatted(date: .long, time: .shortened))
|
||||
}
|
||||
if inspection.mobileLocalId != nil {
|
||||
infoRow(icon: "ipad", text: "Submitted from this device")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
// ── Flagged issues card ────────────────────────────────────────────────
|
||||
|
||||
private func issuesCard(_ issues: [LocalIssue]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Flagged Issues (\(issues.count))")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(issues) { issue in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
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, 5)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(issue.severity.capitalized)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(issue.issueDescription)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
// ── Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
private func infoRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 18)
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReadOnlyGridFormView
|
||||
// Renders a submitted form in the same 12-column grid as ExecuteInspectionView
|
||||
// but with all inputs disabled/display-only — no editing allowed.
|
||||
|
||||
struct ReadOnlyGridFormView: View {
|
||||
|
||||
let schema: [[String: Any]]
|
||||
let formValues: [String: String]
|
||||
|
||||
private struct ReadOnlyWidthKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
static let totalColumns: Int = 12
|
||||
static let cellGap: CGFloat = 8
|
||||
static let rowGap: CGFloat = 4
|
||||
static let cellAspect: CGFloat = 52/72
|
||||
static let cardPadding: CGFloat = 16
|
||||
|
||||
@State private var containerWidth: CGFloat = 952
|
||||
|
||||
private var computedCellW: CGFloat {
|
||||
(containerWidth - 2 * Self.cardPadding
|
||||
- CGFloat(Self.totalColumns - 1) * Self.cellGap)
|
||||
/ CGFloat(Self.totalColumns)
|
||||
}
|
||||
|
||||
private func canvasHeight() -> CGFloat {
|
||||
let cellH = computedCellW * Self.cellAspect
|
||||
let maxRow = schema.reduce(0) { acc, f in
|
||||
max(acc, (f["row"] as? Int ?? 1) + (f["rowSpan"] as? Int ?? 2) - 1)
|
||||
}
|
||||
return CGFloat(maxRow) * cellH + CGFloat(max(maxRow - 1, 0)) * Self.rowGap
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity).frame(height: 0)
|
||||
.background(GeometryReader { geo in
|
||||
Color.clear.preference(key: ReadOnlyWidthKey.self, value: geo.size.width)
|
||||
})
|
||||
|
||||
let cellW = computedCellW
|
||||
let cellH = cellW * Self.cellAspect
|
||||
|
||||
ForEach(schema.indices, id: \.self) { idx in
|
||||
let field = schema[idx]
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
if !["button_submit", "button_print", "button_email"].contains(ftype) {
|
||||
readOnlyCell(field: field, cellW: cellW, cellH: cellH)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(ReadOnlyWidthKey.self) { if $0 > 0 { containerWidth = $0 } }
|
||||
.frame(height: canvasHeight() + 2 * Self.cardPadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func readOnlyCell(field: [String: Any], cellW: CGFloat, cellH: CGFloat) -> some View {
|
||||
let col = max(1, field["col"] as? Int ?? 1)
|
||||
let row = max(1, field["row"] as? Int ?? 1)
|
||||
let colSpan = max(1, field["colSpan"] as? Int ?? 6)
|
||||
let rowSpan = max(1, field["rowSpan"] as? Int ?? 2)
|
||||
|
||||
let xOff = CGFloat(col - 1) * (cellW + Self.cellGap) + Self.cardPadding
|
||||
let yOff = 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
|
||||
|
||||
let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? ""
|
||||
let value = formValues[fid] ?? ""
|
||||
let ftype = field["type"] as? String ?? "text"
|
||||
let label = field["label"] as? String ?? ""
|
||||
|
||||
ReadOnlyCellView(field: field, value: value, fieldType: ftype, label: label)
|
||||
.frame(width: width, height: height, alignment: .topLeading)
|
||||
.offset(x: xOff, y: yOff)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ReadOnlyCellView
|
||||
// Displays a single form cell as plain text — no editable controls.
|
||||
|
||||
struct ReadOnlyCellView: View {
|
||||
let field: [String: Any]
|
||||
let value: String
|
||||
let fieldType: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Label (same as GridCellContentView)
|
||||
if !["section", "label", "checkbox",
|
||||
"button_submit", "button_print", "button_email"].contains(fieldType),
|
||||
!label.isEmpty {
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(.secondaryLabel))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
// Value display
|
||||
valueView
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var valueView: some View {
|
||||
switch fieldType {
|
||||
|
||||
case "section":
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Divider()
|
||||
Text(label)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(Color(.label))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
case "label":
|
||||
let fsMap: [String: CGFloat] = ["small": 11, "normal": 13, "large": 15, "x-large": 18]
|
||||
let fs = fsMap[field["font_size"] as? String ?? "normal"] ?? 13
|
||||
let fw: Font.Weight = (field["font_weight"] as? String == "bold") ? .bold : .regular
|
||||
Text(field["text_content"] as? String ?? "")
|
||||
.font(.system(size: fs, weight: fw))
|
||||
.foregroundStyle(Color(.label))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
case "checkbox":
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: value == "true" ? "checkmark.square.fill" : "square")
|
||||
.foregroundStyle(value == "true" ? .blue : Color(.systemGray3))
|
||||
.font(.system(size: 14))
|
||||
Text(label)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color(.secondaryLabel))
|
||||
}
|
||||
|
||||
case "pass_fail":
|
||||
let options = field["options"] as? [String] ?? ["Pass", "Fail"]
|
||||
HStack(spacing: 6) {
|
||||
ForEach(options, id: \.self) { opt in
|
||||
let isPass = ["pass","yes","ok","good","acceptable","compliant"].contains(opt.lowercased())
|
||||
let isActive = value == opt
|
||||
Text(opt)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||||
.background(isActive ? (isPass ? Color.green : Color.red) : Color.clear)
|
||||
.foregroundStyle(isActive ? .white : (isPass ? Color.green : Color.red))
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(isPass ? Color.green : Color.red, lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
|
||||
case "rating":
|
||||
let intVal = Int(value) ?? 0
|
||||
let maxRating = field["max"] as? Int ?? 5
|
||||
HStack(spacing: 2) {
|
||||
ForEach(1...Swift.max(maxRating, 1), id: \.self) { star in
|
||||
Text("★")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(star <= intVal ? Color.yellow : Color(.systemGray4))
|
||||
}
|
||||
}
|
||||
|
||||
case "image":
|
||||
if value.hasPrefix("local://") {
|
||||
// Photo taken on this device — may still be on disk
|
||||
let path = String(value.dropFirst("local://".count))
|
||||
if let img = UIImage(contentsOfFile: path) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
} else {
|
||||
// Local file cleaned up — show placeholder
|
||||
Label("Photo no longer on device", systemImage: "photo.badge.exclamationmark")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if value.hasPrefix("uploads/") {
|
||||
// Photo synced to server — load via AsyncImage
|
||||
let url = URL(string: "\(Constants.baseURL)/static/\(value)")
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
img.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
case .failure:
|
||||
Label("Could not load photo", systemImage: "photo.badge.exclamationmark")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
case .empty:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
Text("Loading photo…")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
} else if !value.isEmpty {
|
||||
// Unknown path format — generic indicator
|
||||
Label("Photo attached", systemImage: "photo")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("—")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color(.tertiaryLabel))
|
||||
}
|
||||
|
||||
default:
|
||||
// Text, textarea, number, email, date, select, radio, checkbox_group
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(value.isEmpty ? Color(.tertiaryLabel) : Color(.label))
|
||||
.lineLimit(3)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.overlay(RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(Color(.systemGray5), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user