06/22 Fix High impact items

This commit is contained in:
Nguyen Ngo
2026-06-22 11:10:37 -04:00
parent 6c48a38743
commit e68a702bf9
4 changed files with 309 additions and 48 deletions
+196 -37
View File
@@ -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)