06/22 Fix High impact items
This commit is contained in:
@@ -773,6 +773,10 @@ struct SyncStatusView: View {
|
||||
sort: \LocalIssue.createdAt
|
||||
) 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 {
|
||||
List {
|
||||
Section("Status") {
|
||||
@@ -800,6 +804,22 @@ struct SyncStatusView: View {
|
||||
Label("Sync Now", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.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 {
|
||||
@@ -831,6 +851,25 @@ struct SyncStatusView: View {
|
||||
}
|
||||
.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 {
|
||||
@@ -907,21 +946,64 @@ struct IssuesListView: View {
|
||||
order: .reverse
|
||||
) private var allIssues: [LocalIssue]
|
||||
|
||||
private var issues: [LocalIssue] {
|
||||
allIssues.filter { $0.issueStatus != "resolved" }
|
||||
@Environment(\.modelContext) private var context
|
||||
@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
|
||||
@State private var showNewIssue = false
|
||||
private var issues: [LocalIssue] {
|
||||
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 {
|
||||
Group {
|
||||
if issues.isEmpty {
|
||||
if allIssues.filter({ $0.issueStatus != "resolved" }).isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Issues",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text("Tap + to log a new issue, or flag one during an inspection.")
|
||||
)
|
||||
} else if issues.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List(issues) { issue in
|
||||
NavigationLink(value: issue) {
|
||||
@@ -931,11 +1013,42 @@ struct IssuesListView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Issues (\(issues.count))")
|
||||
.searchable(text: $searchText,
|
||||
prompt: "Search ID, date, description, facility…")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button { showNewIssue = true } label: {
|
||||
Image(systemName: "plus")
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
// Severity filter
|
||||
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) {
|
||||
@@ -1803,48 +1916,94 @@ struct NotificationsView: View {
|
||||
/// 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
|
||||
/// 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 {
|
||||
let url: URL?
|
||||
@State private var reloadToken = UUID()
|
||||
@State private var cached: UIImage? = nil
|
||||
|
||||
var body: some View {
|
||||
AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
Group {
|
||||
if let img = cached {
|
||||
// Cache hit — instant display, no spinner, no network
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
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)
|
||||
} else {
|
||||
AsyncImage(url: url, transaction: Transaction(animation: .easeIn)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.onAppear {
|
||||
// Store into cache so next appearance is instant
|
||||
if let url, let ui = ImageRenderer(content: image).uiImage {
|
||||
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)
|
||||
.padding(.vertical, 12)
|
||||
case .empty:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Loading…").font(.caption).foregroundStyle(.secondary)
|
||||
.id(reloadToken)
|
||||
.onAppear {
|
||||
// Check cache before AsyncImage fires a network request
|
||||
if let url, let img = PhotoCache.shared.get(url) {
|
||||
cached = img
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
@unknown default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.id(reloadToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,12 @@ struct ExecuteInspectionView: View {
|
||||
let inspection: LocalInspection
|
||||
|
||||
@State private var formValues: [String: String] = [:]
|
||||
@State private var showFlagIssue = false
|
||||
@State private var showSubmitAlert = false
|
||||
@State private var showNoGPSAlert = false
|
||||
@State private var isSaving = false
|
||||
@State private var showFlagIssue = false
|
||||
@State private var showSubmitAlert = false
|
||||
@State private var showNoGPSAlert = false
|
||||
@State private var showValidationAlert = false
|
||||
@State private var missingFields: [String] = []
|
||||
@State private var isSaving = false
|
||||
@State private var isSubmitting = false
|
||||
@State private var submitResult: SubmitResult?
|
||||
|
||||
@@ -125,6 +127,15 @@ struct ExecuteInspectionView: View {
|
||||
} 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.")
|
||||
}
|
||||
.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
|
||||
// Begin acquiring a GPS fix the moment the confirm dialog appears
|
||||
// so a location is likely ready by the time the inspector taps Submit.
|
||||
@@ -284,7 +295,16 @@ struct ExecuteInspectionView: View {
|
||||
.disabled(isSaving)
|
||||
|
||||
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: {
|
||||
Group {
|
||||
if isSubmitting {
|
||||
@@ -380,6 +400,43 @@ struct ExecuteInspectionView: View {
|
||||
|
||||
// ── 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 {
|
||||
isSubmitting = true
|
||||
|
||||
|
||||
@@ -17,6 +17,46 @@ struct InspectionHistoryView: View {
|
||||
@State private var offset = 0
|
||||
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 {
|
||||
Group {
|
||||
if !sync.isOnline && inspections.isEmpty {
|
||||
@@ -40,15 +80,18 @@ struct InspectionHistoryView: View {
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
description: Text("Completed inspections will appear here after syncing.")
|
||||
)
|
||||
} else if filteredInspections.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List {
|
||||
ForEach(inspections) { inspection in
|
||||
ForEach(filteredInspections) { inspection in
|
||||
NavigationLink(value: 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 {
|
||||
HStack {
|
||||
Spacer()
|
||||
@@ -76,6 +119,8 @@ struct InspectionHistoryView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Inspection History")
|
||||
.searchable(text: $searchText,
|
||||
prompt: "Search ID, date, facility, area…")
|
||||
.task {
|
||||
if sync.isOnline {
|
||||
await load(reset: true)
|
||||
|
||||
Reference in New Issue
Block a user