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)
}
}