06/22 Fix High impact items
This commit is contained in:
@@ -411,7 +411,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -431,7 +431,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
DEVELOPMENT_TEAM = SB7DNYC9TY;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -474,7 +474,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
PRODUCT_BUNDLE_IDENTIFIER = com.ltservicesinc.JanitorialQC;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -773,6 +773,10 @@ struct SyncStatusView: View {
|
|||||||
sort: \LocalIssue.createdAt
|
sort: \LocalIssue.createdAt
|
||||||
) private var pendingIssues: [LocalIssue]
|
) private var pendingIssues: [LocalIssue]
|
||||||
|
|
||||||
|
private var failedInspections: [LocalInspection] { pendingInspections.filter { $0.syncStatus == "failed" } }
|
||||||
|
private var failedIssues: [LocalIssue] { pendingIssues.filter { $0.syncStatus == "failed" } }
|
||||||
|
private var hasFailedItems: Bool { !failedInspections.isEmpty || !failedIssues.isEmpty }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section("Status") {
|
Section("Status") {
|
||||||
@@ -800,6 +804,22 @@ struct SyncStatusView: View {
|
|||||||
Label("Sync Now", systemImage: "arrow.clockwise")
|
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||||
}
|
}
|
||||||
.disabled(!sync.isOnline || sync.isSyncing)
|
.disabled(!sync.isOnline || sync.isSyncing)
|
||||||
|
|
||||||
|
// Retry Failed Items — resets syncStatus back to "pending" so
|
||||||
|
// the next triggerSync() will re-attempt them. Once an item
|
||||||
|
// reaches syncStatus = "failed" (after 5 consecutive errors)
|
||||||
|
// triggerSync() stops picking it up — this is the only way
|
||||||
|
// to re-queue it without manual server intervention.
|
||||||
|
if hasFailedItems {
|
||||||
|
Button {
|
||||||
|
retryAllFailed()
|
||||||
|
} label: {
|
||||||
|
Label("Retry Failed Items (\(failedInspections.count + failedIssues.count))",
|
||||||
|
systemImage: "exclamationmark.arrow.circlepath")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
.disabled(!sync.isOnline || sync.isSyncing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !pendingInspections.isEmpty {
|
if !pendingInspections.isEmpty {
|
||||||
@@ -831,6 +851,25 @@ struct SyncStatusView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Pending Sync")
|
.navigationTitle("Pending Sync")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset all failed items back to pending so the next sync pass picks them up.
|
||||||
|
/// Also clears syncRetryCount and syncErrorMessage so the retry counter
|
||||||
|
/// starts fresh — prevents them immediately hitting the 5-retry cap again
|
||||||
|
/// without any actual new attempt.
|
||||||
|
private func retryAllFailed() {
|
||||||
|
for insp in failedInspections {
|
||||||
|
insp.syncStatus = "pending"
|
||||||
|
insp.syncRetryCount = 0
|
||||||
|
insp.syncErrorMessage = nil
|
||||||
|
}
|
||||||
|
for issue in failedIssues {
|
||||||
|
issue.syncStatus = "pending"
|
||||||
|
issue.syncRetryCount = 0
|
||||||
|
issue.syncErrorMessage = nil
|
||||||
|
}
|
||||||
|
try? context.save()
|
||||||
|
Task { await sync.triggerSync() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SyncRowView: View {
|
struct SyncRowView: View {
|
||||||
@@ -907,21 +946,64 @@ struct IssuesListView: View {
|
|||||||
order: .reverse
|
order: .reverse
|
||||||
) private var allIssues: [LocalIssue]
|
) private var allIssues: [LocalIssue]
|
||||||
|
|
||||||
private var issues: [LocalIssue] {
|
@Environment(\.modelContext) private var context
|
||||||
allIssues.filter { $0.issueStatus != "resolved" }
|
@State private var showNewIssue = false
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var severityFilter: String? = nil // nil = all
|
||||||
|
|
||||||
|
private let severities = ["critical", "high", "medium", "low"]
|
||||||
|
|
||||||
|
/// Date formatter shared for search matching.
|
||||||
|
/// Formats to e.g. "Jun 19, 2026 4:55 PM" so inspectors can type
|
||||||
|
/// partial strings: "jun", "2026", "19", "4:55" all match.
|
||||||
|
private static let searchDateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func dateString(for issue: LocalIssue) -> String {
|
||||||
|
let d = issue.serverReportedAt ?? issue.createdAt
|
||||||
|
return Self.searchDateFmt.string(from: d)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Environment(\.modelContext) private var context
|
private var issues: [LocalIssue] {
|
||||||
@State private var showNewIssue = false
|
var list = allIssues.filter { $0.issueStatus != "resolved" }
|
||||||
|
|
||||||
|
if let sev = severityFilter {
|
||||||
|
list = list.filter { $0.severity == sev }
|
||||||
|
}
|
||||||
|
|
||||||
|
if !searchText.isEmpty {
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
list = list.filter {
|
||||||
|
// ID — match "#58" or bare "58"
|
||||||
|
let idStr = $0.serverId.map { String($0) } ?? ""
|
||||||
|
let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "")
|
||||||
|
|
||||||
|
return idMatch
|
||||||
|
|| $0.issueDescription.lowercased().contains(q)
|
||||||
|
|| ($0.facilityNameCache?.lowercased().contains(q) ?? false)
|
||||||
|
|| ($0.areaNameCache?.lowercased().contains(q) ?? false)
|
||||||
|
|| ($0.assignedToName?.lowercased().contains(q) ?? false)
|
||||||
|
|| dateString(for: $0).lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if issues.isEmpty {
|
if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No Issues",
|
"No Issues",
|
||||||
systemImage: "exclamationmark.triangle",
|
systemImage: "exclamationmark.triangle",
|
||||||
description: Text("Tap + to log a new issue, or flag one during an inspection.")
|
description: Text("Tap + to log a new issue, or flag one during an inspection.")
|
||||||
)
|
)
|
||||||
|
} else if issues.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
} else {
|
} else {
|
||||||
List(issues) { issue in
|
List(issues) { issue in
|
||||||
NavigationLink(value: issue) {
|
NavigationLink(value: issue) {
|
||||||
@@ -931,11 +1013,42 @@ struct IssuesListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Issues (\(issues.count))")
|
.navigationTitle("Issues (\(issues.count))")
|
||||||
|
.searchable(text: $searchText,
|
||||||
|
prompt: "Search ID, date, description, facility…")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Button { showNewIssue = true } label: {
|
// Severity filter
|
||||||
Image(systemName: "plus")
|
Menu {
|
||||||
|
Button {
|
||||||
|
severityFilter = nil
|
||||||
|
} label: {
|
||||||
|
Label("All Severities",
|
||||||
|
systemImage: severityFilter == nil ? "checkmark" : "line.3.horizontal.decrease")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ForEach(severities, id: \.self) { sev in
|
||||||
|
Button {
|
||||||
|
severityFilter = (severityFilter == sev) ? nil : sev
|
||||||
|
} label: {
|
||||||
|
Label(sev.capitalized,
|
||||||
|
systemImage: severityFilter == sev ? "checkmark" : "circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: severityFilter != nil
|
||||||
|
? "line.3.horizontal.decrease.circle.fill"
|
||||||
|
: "line.3.horizontal.decrease.circle")
|
||||||
|
.foregroundStyle(severityFilter != nil ? .orange : .primary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New Issue — borderedProminent so it stands out clearly
|
||||||
|
// from the filter icon and is easy to find at a glance.
|
||||||
|
Button {
|
||||||
|
showNewIssue = true
|
||||||
|
} label: {
|
||||||
|
Label("New Issue", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showNewIssue) {
|
.sheet(isPresented: $showNewIssue) {
|
||||||
@@ -1803,48 +1916,94 @@ struct NotificationsView: View {
|
|||||||
/// AsyncImage has no built-in retry — once it enters .failure it stays there
|
/// AsyncImage has no built-in retry — once it enters .failure it stays there
|
||||||
/// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and
|
/// for the view's lifetime. Toggling the `id` forces SwiftUI to destroy and
|
||||||
/// recreate the AsyncImage, triggering a fresh network load.
|
/// recreate the AsyncImage, triggering a fresh network load.
|
||||||
|
// MARK: - PhotoCache
|
||||||
|
// Simple NSCache-backed in-memory image cache keyed by URL string.
|
||||||
|
// Prevents RetryablePhotoView from re-downloading the same photo on every
|
||||||
|
// view appearance (AsyncImage only caches within a single URLSession load;
|
||||||
|
// revisiting IssueDetailView or scrolling the inspection history starts a
|
||||||
|
// fresh download). Cache entries are evicted automatically by the OS under
|
||||||
|
// memory pressure — no manual lifetime management needed.
|
||||||
|
|
||||||
|
final class PhotoCache {
|
||||||
|
static let shared = PhotoCache()
|
||||||
|
private let cache = NSCache<NSString, UIImage>()
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
cache.countLimit = 150 // max images in memory
|
||||||
|
cache.totalCostLimit = 80_000_000 // ~80 MB total
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(_ url: URL) -> UIImage? { cache.object(forKey: url.absoluteString as NSString) }
|
||||||
|
func set(_ image: UIImage, for url: URL) { cache.setObject(image, forKey: url.absoluteString as NSString,
|
||||||
|
cost: Int(image.size.width * image.size.height * 4)) }
|
||||||
|
}
|
||||||
|
|
||||||
struct RetryablePhotoView: View {
|
struct RetryablePhotoView: View {
|
||||||
let url: URL?
|
let url: URL?
|
||||||
@State private var reloadToken = UUID()
|
@State private var reloadToken = UUID()
|
||||||
|
@State private var cached: UIImage? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in
|
Group {
|
||||||
switch phase {
|
if let img = cached {
|
||||||
case .success(let image):
|
// Cache hit — instant display, no spinner, no network
|
||||||
image
|
Image(uiImage: img)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
case .failure:
|
} else {
|
||||||
VStack(spacing: 8) {
|
AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in
|
||||||
Image(systemName: "exclamationmark.triangle")
|
switch phase {
|
||||||
.foregroundStyle(.secondary)
|
case .success(let image):
|
||||||
Text("Photo unavailable")
|
image
|
||||||
.font(.caption)
|
.resizable()
|
||||||
.foregroundStyle(.secondary)
|
.scaledToFit()
|
||||||
Button {
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
reloadToken = UUID()
|
.onAppear {
|
||||||
} label: {
|
// Store into cache so next appearance is instant
|
||||||
Label("Retry", systemImage: "arrow.clockwise")
|
if let url, let ui = ImageRenderer(content: image).uiImage {
|
||||||
.font(.caption)
|
PhotoCache.shared.set(ui, for: url)
|
||||||
|
cached = ui
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure:
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Photo unavailable")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
reloadToken = UUID()
|
||||||
|
} label: {
|
||||||
|
Label("Retry", systemImage: "arrow.clockwise")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
case .empty:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.id(reloadToken)
|
||||||
.padding(.vertical, 12)
|
.onAppear {
|
||||||
case .empty:
|
// Check cache before AsyncImage fires a network request
|
||||||
HStack(spacing: 8) {
|
if let url, let img = PhotoCache.shared.get(url) {
|
||||||
ProgressView()
|
cached = img
|
||||||
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
@unknown default:
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.id(reloadToken)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ struct ExecuteInspectionView: View {
|
|||||||
let inspection: LocalInspection
|
let inspection: LocalInspection
|
||||||
|
|
||||||
@State private var formValues: [String: String] = [:]
|
@State private var formValues: [String: String] = [:]
|
||||||
@State private var showFlagIssue = false
|
@State private var showFlagIssue = false
|
||||||
@State private var showSubmitAlert = false
|
@State private var showSubmitAlert = false
|
||||||
@State private var showNoGPSAlert = false
|
@State private var showNoGPSAlert = false
|
||||||
@State private var isSaving = false
|
@State private var showValidationAlert = false
|
||||||
|
@State private var missingFields: [String] = []
|
||||||
|
@State private var isSaving = false
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var submitResult: SubmitResult?
|
@State private var submitResult: SubmitResult?
|
||||||
|
|
||||||
@@ -125,6 +127,15 @@ struct ExecuteInspectionView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.")
|
Text("This inspection's location could not be recorded — Location permission may be denied, or no GPS signal is available right now. You can submit without it, or wait a moment and try again.")
|
||||||
}
|
}
|
||||||
|
.alert("Required Fields Missing", isPresented: $showValidationAlert) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
let list = missingFields.prefix(5).joined(separator: "\n• ")
|
||||||
|
let suffix = missingFields.count > 5
|
||||||
|
? "\n…and \(missingFields.count - 5) more."
|
||||||
|
: ""
|
||||||
|
Text("Please fill in the following required fields before submitting:\n\n• \(list)\(suffix)")
|
||||||
|
}
|
||||||
.onChange(of: showSubmitAlert) { _, showing in
|
.onChange(of: showSubmitAlert) { _, showing in
|
||||||
// Begin acquiring a GPS fix the moment the confirm dialog appears
|
// Begin acquiring a GPS fix the moment the confirm dialog appears
|
||||||
// so a location is likely ready by the time the inspector taps Submit.
|
// so a location is likely ready by the time the inspector taps Submit.
|
||||||
@@ -284,7 +295,16 @@ struct ExecuteInspectionView: View {
|
|||||||
.disabled(isSaving)
|
.disabled(isSaving)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showSubmitAlert = true
|
// Validate required fields before showing the confirm dialog.
|
||||||
|
// Collects every unfilled required field's label so the alert
|
||||||
|
// can name them specifically rather than just saying "missing fields".
|
||||||
|
let missing = missingRequiredFields()
|
||||||
|
if missing.isEmpty {
|
||||||
|
showSubmitAlert = true
|
||||||
|
} else {
|
||||||
|
missingFields = missing
|
||||||
|
showValidationAlert = true
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Group {
|
Group {
|
||||||
if isSubmitting {
|
if isSubmitting {
|
||||||
@@ -380,6 +400,43 @@ struct ExecuteInspectionView: View {
|
|||||||
|
|
||||||
// ── Submit (async — shows result, then dismisses) ─────────────────────
|
// ── Submit (async — shows result, then dismisses) ─────────────────────
|
||||||
|
|
||||||
|
/// Returns the labels of required fields that have no value in formValues.
|
||||||
|
/// Skips structural fields (section, label, buttons) and image/signature
|
||||||
|
/// fields since those have complex "filled" semantics (local:// counts as
|
||||||
|
/// filled — the photo exists even if not yet uploaded to the server).
|
||||||
|
private func missingRequiredFields() -> [String] {
|
||||||
|
let skipTypes: Set<String> = ["section", "label",
|
||||||
|
"button_submit", "button_print", "button_email"]
|
||||||
|
var missing: [String] = []
|
||||||
|
for field in formSchema {
|
||||||
|
guard let ftype = field["type"] as? String,
|
||||||
|
!skipTypes.contains(ftype),
|
||||||
|
field["required"] as? Bool == true
|
||||||
|
else { continue }
|
||||||
|
|
||||||
|
let fid = field["id"] as? String ?? (field["id"] as? Int).map(String.init) ?? ""
|
||||||
|
let val = formValues[fid] ?? ""
|
||||||
|
|
||||||
|
let isFilled: Bool
|
||||||
|
switch ftype {
|
||||||
|
case "rating":
|
||||||
|
isFilled = (Int(val) ?? 0) > 0
|
||||||
|
case "image", "signature":
|
||||||
|
isFilled = !val.isEmpty // local:// or uploads/ both count
|
||||||
|
case "checkbox":
|
||||||
|
isFilled = val == "true"
|
||||||
|
default:
|
||||||
|
isFilled = !val.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isFilled {
|
||||||
|
let label = field["label"] as? String ?? "Field \(fid)"
|
||||||
|
missing.append(label.isEmpty ? "Field \(fid)" : label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
private func submitInspection() async {
|
private func submitInspection() async {
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,46 @@ struct InspectionHistoryView: View {
|
|||||||
@State private var offset = 0
|
@State private var offset = 0
|
||||||
private let limit = 30
|
private let limit = 30
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
|
||||||
|
/// Date formatter for search matching — formats to e.g. "Jun 19, 2026 4:55 PM"
|
||||||
|
/// so partial strings like "jun", "2026", "19" all match.
|
||||||
|
private static let searchDateFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateStyle = .medium
|
||||||
|
f.timeStyle = .short
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func dateString(for insp: APIInspectionSummary) -> String {
|
||||||
|
guard let d = insp.inspectionDateParsed else { return insp.inspectionDate ?? "" }
|
||||||
|
return Self.searchDateFmt.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client-side filter over the already-fetched page.
|
||||||
|
/// Matches: ID (exact or #-prefixed), date string (partial), facility
|
||||||
|
/// name, area name (together = "location"). Load More still fetches more
|
||||||
|
/// server pages so the inspector isn't limited to the first 30 results
|
||||||
|
/// when searching by date or location.
|
||||||
|
private var filteredInspections: [APIInspectionSummary] {
|
||||||
|
guard !searchText.isEmpty else { return inspections }
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
return inspections.filter {
|
||||||
|
// ID — match "#12" or bare "12"
|
||||||
|
let idStr = String($0.id)
|
||||||
|
let idMatch = idStr == q || idStr == q.replacingOccurrences(of: "#", with: "")
|
||||||
|
|
||||||
|
// Location = facility name + area name
|
||||||
|
let locationMatch = $0.facilityName.lowercased().contains(q)
|
||||||
|
|| ($0.areaName?.lowercased().contains(q) ?? false)
|
||||||
|
|
||||||
|
return idMatch
|
||||||
|
|| locationMatch
|
||||||
|
|| dateString(for: $0).lowercased().contains(q)
|
||||||
|
|| $0.templateName.lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if !sync.isOnline && inspections.isEmpty {
|
if !sync.isOnline && inspections.isEmpty {
|
||||||
@@ -40,15 +80,18 @@ struct InspectionHistoryView: View {
|
|||||||
systemImage: "clock.arrow.circlepath",
|
systemImage: "clock.arrow.circlepath",
|
||||||
description: Text("Completed inspections will appear here after syncing.")
|
description: Text("Completed inspections will appear here after syncing.")
|
||||||
)
|
)
|
||||||
|
} else if filteredInspections.isEmpty {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(inspections) { inspection in
|
ForEach(filteredInspections) { inspection in
|
||||||
NavigationLink(value: inspection) {
|
NavigationLink(value: inspection) {
|
||||||
HistoryRowView(inspection: inspection)
|
HistoryRowView(inspection: inspection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load more
|
// Load More — still available while searching so the
|
||||||
|
// inspector can expand beyond the current page.
|
||||||
if inspections.count < total {
|
if inspections.count < total {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -76,6 +119,8 @@ struct InspectionHistoryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Inspection History")
|
.navigationTitle("Inspection History")
|
||||||
|
.searchable(text: $searchText,
|
||||||
|
prompt: "Search ID, date, facility, area…")
|
||||||
.task {
|
.task {
|
||||||
if sync.isOnline {
|
if sync.isOnline {
|
||||||
await load(reset: true)
|
await load(reset: true)
|
||||||
|
|||||||
Reference in New Issue
Block a user