729 lines
30 KiB
Swift
729 lines
30 KiB
Swift
// Views/Dashboard/DashboardView.swift
|
|
// ------------------------------------
|
|
// Phase C: adds Inspection History tab, polished Settings with cache clear,
|
|
// and schedules background sync on scene enter background.
|
|
//
|
|
// CHANGED (sidebar update):
|
|
// - Templates removed from sidebar entirely.
|
|
// - Issues view added for all roles.
|
|
// - History moved to sit between Pending Sync and Settings.
|
|
// - Tab identity is now enum-based (SidebarTab) instead of raw Int
|
|
// so adding/removing tabs never breaks the detail switch.
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Combine
|
|
import MessageUI
|
|
|
|
// MARK: - SidebarTab
|
|
|
|
enum SidebarTab: Hashable {
|
|
case dashboard // landing page — KPI stats card
|
|
case myInspections
|
|
case issues
|
|
case facilities
|
|
case pendingSync
|
|
case history
|
|
case notifications // in-app notification inbox
|
|
case settings
|
|
}
|
|
|
|
struct DashboardView: View {
|
|
|
|
@EnvironmentObject private var auth: AuthManager
|
|
@EnvironmentObject private var sync: SyncManager
|
|
@Environment(\.modelContext) private var context
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.horizontalSizeClass) private var hSizeClass
|
|
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status != "synced" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var myInspections: [LocalInspection]
|
|
|
|
@State private var selectedTab: SidebarTab = .dashboard
|
|
/// Each sidebar tap refreshes the UUID for that tab, forcing its
|
|
/// NavigationStack to be destroyed and recreated — even when the tab
|
|
/// hasn't changed (user is already on it but deep inside a detail view).
|
|
@State private var tabResetId: [SidebarTab: UUID] = [
|
|
.dashboard: UUID(),
|
|
.myInspections: UUID(),
|
|
.issues: UUID(),
|
|
.facilities: UUID(),
|
|
.pendingSync: UUID(),
|
|
.history: UUID(),
|
|
.notifications: UUID(),
|
|
.settings: UUID(),
|
|
]
|
|
|
|
/// Explicit paths for the three tabs that push detail views.
|
|
/// Resetting these to empty pops the stack to root immediately and reliably.
|
|
@State private var inspectionsPath = NavigationPath()
|
|
@State private var issuesPath = NavigationPath()
|
|
@State private var historyPath = NavigationPath()
|
|
|
|
/// Tap a sidebar tab: reset all navigable paths, then switch to it.
|
|
private func selectTab(_ tab: SidebarTab) {
|
|
inspectionsPath = NavigationPath()
|
|
issuesPath = NavigationPath()
|
|
historyPath = NavigationPath()
|
|
tabResetId[tab] = UUID()
|
|
selectedTab = tab
|
|
}
|
|
|
|
/// Sidebar order — single source of truth for both the regular-width
|
|
/// sidebar and the compact-width root list.
|
|
private let sidebarTabs: [SidebarTab] = [
|
|
.dashboard, .myInspections, .issues, .facilities,
|
|
.pendingSync, .history, .notifications, .settings,
|
|
]
|
|
|
|
var body: some View {
|
|
Group {
|
|
// On compact width (iPhone) a NavigationSplitView collapses to show
|
|
// ONLY the sidebar: its `detail:` column is never presented, because
|
|
// nothing pushes it. The rows here are plain Buttons driving @State
|
|
// (rule 2 forbids a `selection:` binding), and a state change alone
|
|
// cannot push the detail column — so every destination was
|
|
// unreachable on iPhone. Compact width therefore gets a real
|
|
// NavigationStack whose rows are NavigationLinks.
|
|
if hSizeClass == .compact {
|
|
compactBody
|
|
} else {
|
|
regularBody
|
|
}
|
|
}
|
|
.task {
|
|
if sync.isOnline {
|
|
await sync.triggerSync()
|
|
} else {
|
|
sync.updatePendingCount(context: context)
|
|
}
|
|
}
|
|
// Schedule background sync when app is backgrounded
|
|
.onChange(of: scenePhase) {
|
|
if scenePhase == .background {
|
|
scheduleBackgroundSync()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Regular width (iPad) — unchanged two-column split view ────────────
|
|
|
|
private var regularBody: some View {
|
|
NavigationSplitView {
|
|
List {
|
|
ForEach(sidebarTabs, id: \.self) { tab in
|
|
Button {
|
|
selectTab(tab)
|
|
if tab == .notifications { sync.markNotificationsViewed() }
|
|
} label: {
|
|
sidebarRowLabel(tab, tinted: selectedTab == tab)
|
|
}
|
|
.listRowBackground(
|
|
selectedTab == tab ? Color.blue.opacity(0.1) : Color.clear
|
|
)
|
|
}
|
|
}
|
|
.navigationTitle("JQC Inspector")
|
|
.listStyle(.sidebar)
|
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
|
|
|
} detail: {
|
|
switch selectedTab {
|
|
case .myInspections:
|
|
NavigationStack(path: $inspectionsPath) { detailRoot(for: .myInspections) }
|
|
case .issues:
|
|
NavigationStack(path: $issuesPath) { detailRoot(for: .issues) }
|
|
case .history:
|
|
NavigationStack(path: $historyPath) { detailRoot(for: .history) }
|
|
default:
|
|
NavigationStack { detailRoot(for: selectedTab) }
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Compact width (iPhone) — push-based stack ─────────────────────────
|
|
// One NavigationStack whose root is the same destination list. Rows are
|
|
// NavigationLinks so tapping actually pushes. The per-tab paths used by
|
|
// the iPad split view are not needed here: this single stack owns the
|
|
// whole hierarchy, and the nested `.navigationDestination`s declared in
|
|
// detailRoot(for:) register against it.
|
|
|
|
private var compactBody: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(sidebarTabs, id: \.self) { tab in
|
|
NavigationLink(value: tab) {
|
|
sidebarRowLabel(tab, tinted: false)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("JQC Inspector")
|
|
.navigationDestination(for: SidebarTab.self) { detailRoot(for: $0) }
|
|
.safeAreaInset(edge: .bottom) { syncStatusFooter }
|
|
}
|
|
}
|
|
|
|
// ── Shared row label ──────────────────────────────────────────────────
|
|
|
|
@ViewBuilder
|
|
private func sidebarRowLabel(_ tab: SidebarTab, tinted: Bool) -> some View {
|
|
let tint: Color = tinted ? .blue : .primary
|
|
switch tab {
|
|
case .dashboard:
|
|
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
|
.foregroundStyle(tint)
|
|
|
|
case .myInspections:
|
|
HStack {
|
|
Label("My Inspections", systemImage: "checklist")
|
|
.foregroundStyle(tint)
|
|
Spacer()
|
|
if !myInspections.isEmpty {
|
|
Text("\(myInspections.count)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.blue.opacity(0.15))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
case .issues:
|
|
Label("Issues", systemImage: "exclamationmark.triangle")
|
|
.foregroundStyle(tint)
|
|
|
|
case .facilities:
|
|
Label("Facilities", systemImage: "building.2")
|
|
.foregroundStyle(tint)
|
|
|
|
case .pendingSync:
|
|
HStack {
|
|
Label("Pending Sync", systemImage: "arrow.triangle.2.circlepath")
|
|
.foregroundStyle(tint)
|
|
Spacer()
|
|
if sync.pendingCount > 0 {
|
|
Text("\(sync.pendingCount)")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.orange.opacity(0.2))
|
|
.foregroundStyle(.orange)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
case .history:
|
|
Label("History", systemImage: "clock.arrow.circlepath")
|
|
.foregroundStyle(tint)
|
|
|
|
case .notifications:
|
|
HStack {
|
|
Label("Notifications", systemImage: "bell")
|
|
.foregroundStyle(tint)
|
|
Spacer()
|
|
if sync.unreadNotificationCount > 0 {
|
|
Text("\(min(sync.unreadNotificationCount, 99))")
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(Color.red.opacity(0.85))
|
|
.foregroundStyle(.white)
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
|
|
case .settings:
|
|
Label("Settings", systemImage: "gear")
|
|
.foregroundStyle(tint)
|
|
}
|
|
}
|
|
|
|
// ── Shared destination root ───────────────────────────────────────────
|
|
// The NavigationStack wrapper lives at the call site, so the same content
|
|
// serves as a split-view detail root (iPad) and a pushed view (iPhone).
|
|
|
|
@ViewBuilder
|
|
private func detailRoot(for tab: SidebarTab) -> some View {
|
|
switch tab {
|
|
case .dashboard:
|
|
DashboardStatsView()
|
|
|
|
case .myInspections:
|
|
MyInspectionsView()
|
|
.navigationDestination(for: LocalInspection.self) { inspection in
|
|
if inspection.status == "draft" {
|
|
ExecuteInspectionView(inspection: inspection)
|
|
} else {
|
|
CompletedInspectionView(inspection: inspection)
|
|
}
|
|
}
|
|
|
|
case .issues:
|
|
IssuesListView()
|
|
.navigationDestination(for: LocalIssue.self) { issue in
|
|
IssueDetailView(issue: issue)
|
|
}
|
|
|
|
case .facilities:
|
|
FacilitiesListView()
|
|
|
|
case .pendingSync:
|
|
SyncStatusView()
|
|
|
|
case .history:
|
|
InspectionHistoryView()
|
|
.navigationDestination(for: APIInspectionSummary.self) { inspection in
|
|
HistoryDetailView(inspection: inspection)
|
|
}
|
|
|
|
case .notifications:
|
|
NotificationsView()
|
|
|
|
case .settings:
|
|
SettingsView()
|
|
}
|
|
}
|
|
|
|
private var syncStatusFooter: some View {
|
|
VStack(spacing: 0) {
|
|
Divider()
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(sync.isOnline ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(sync.isOnline ? "Online" : "Offline")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
if sync.isSyncing {
|
|
ProgressView().scaleEffect(0.7)
|
|
} else if let lastSync = sync.lastSyncAt {
|
|
Text("Synced \(lastSync.formatted(.relative(presentation: .named)))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dashboard Stats View
|
|
// Shows inspector-scoped KPI cards fetched from GET /api/v1/stats/dashboard.
|
|
// Data is refreshed on every triggerSync() via SyncManager.fetchDashboardStats().
|
|
|
|
struct DashboardStatsView: View {
|
|
|
|
@EnvironmentObject private var sync: SyncManager
|
|
|
|
// Draft inspections — shown as a resume banner at the top of the dashboard
|
|
// so the inspector never has to hunt through My Inspections to find an
|
|
// in-progress form they left open.
|
|
@Query(
|
|
filter: #Predicate<LocalInspection> { $0.status == "draft" },
|
|
sort: \LocalInspection.lastModifiedAt,
|
|
order: .reverse
|
|
) private var draftInspections: [LocalInspection]
|
|
@Environment(\.modelContext) private var context
|
|
|
|
/// Schedule the inspector tapped in ScheduledInspectionsCard. Held here, not
|
|
/// in the card: the card self-hides, and submitting the last scheduled
|
|
/// inspection empties its @Query while the start form is still presented.
|
|
@State private var scheduledStartTarget: ScheduledStartTarget? = nil
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
|
|
// ── Draft Resume Banner ────────────────────────────────────
|
|
if !draftInspections.isEmpty {
|
|
DraftResumeBanner(drafts: draftInspections, context: context)
|
|
}
|
|
|
|
// ── Scheduled Inspections (phase36) ────────────────────────
|
|
// Planned/recurring assignments for this inspector. Self-hides
|
|
// when there are none. Tap a row to start it (facility +
|
|
// template preselected).
|
|
ScheduledInspectionsCard(onStart: { target in
|
|
scheduledStartTarget = target
|
|
})
|
|
|
|
if let stats = sync.dashboardStats {
|
|
// ── Today ──────────────────────────────────────────────
|
|
statsSection(title: "Today") {
|
|
HStack(spacing: 12) {
|
|
statTile(
|
|
value: "\(stats.todayInspections)",
|
|
label: "Inspections",
|
|
icon: "checklist",
|
|
color: .blue
|
|
)
|
|
statTile(
|
|
value: "\(stats.completedToday)",
|
|
label: "Completed",
|
|
icon: "checkmark.circle.fill",
|
|
color: .green
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Issues ─────────────────────────────────────────────
|
|
statsSection(title: "Issues") {
|
|
HStack(spacing: 12) {
|
|
statTile(
|
|
value: "\(stats.openIssues)",
|
|
label: "Open / In Progress",
|
|
icon: "exclamationmark.triangle",
|
|
color: .orange
|
|
)
|
|
statTile(
|
|
value: "\(stats.pendingFollowups)",
|
|
label: "Pending Follow-ups",
|
|
icon: "exclamationmark.arrow.circlepath",
|
|
color: stats.pendingFollowups > 0 ? .orange : .secondary
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── SLA ────────────────────────────────────────────────
|
|
if stats.slaBreached > 0 || stats.slaAtRisk > 0 {
|
|
statsSection(title: "SLA") {
|
|
HStack(spacing: 12) {
|
|
statTile(
|
|
value: "\(stats.slaBreached)",
|
|
label: "Breached",
|
|
icon: "xmark.circle.fill",
|
|
color: stats.slaBreached > 0 ? .red : .secondary
|
|
)
|
|
statTile(
|
|
value: "\(stats.slaAtRisk)",
|
|
label: "At Risk",
|
|
icon: "clock.badge.exclamationmark",
|
|
color: stats.slaAtRisk > 0 ? .orange : .secondary
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Severity breakdown ─────────────────────────────────
|
|
if stats.openIssues > 0 {
|
|
statsSection(title: "Open Issues by Severity") {
|
|
HStack(spacing: 8) {
|
|
if stats.severityCritical > 0 {
|
|
severityTile(count: stats.severityCritical, label: "Critical", color: .red)
|
|
}
|
|
if stats.severityHigh > 0 {
|
|
severityTile(count: stats.severityHigh, label: "High", color: .orange)
|
|
}
|
|
if stats.severityMedium > 0 {
|
|
severityTile(count: stats.severityMedium, label: "Medium", color: .yellow)
|
|
}
|
|
if stats.severityLow > 0 {
|
|
severityTile(count: stats.severityLow, label: "Low", color: .blue)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Score ──────────────────────────────────────────────
|
|
statsSection(title: "Performance (30 days)") {
|
|
if let avg = stats.avgScore30d {
|
|
let color: Color = avg >= 80 ? .green : avg >= 60 ? .orange : .red
|
|
HStack(spacing: 16) {
|
|
Text(String(format: "%.1f%%", avg))
|
|
.font(.system(size: 48, weight: .bold, design: .rounded))
|
|
.foregroundStyle(color)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Average Score")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Text(avg >= 80 ? "Excellent" : avg >= 60 ? "Needs Improvement" : "Below Standard")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(color)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
} else {
|
|
Text("No completed inspections in the last 30 days.")
|
|
.font(.callout)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
} else if !sync.isOnline {
|
|
ContentUnavailableView(
|
|
"Offline",
|
|
systemImage: "wifi.slash",
|
|
description: Text("Dashboard stats require an internet connection.")
|
|
)
|
|
} else {
|
|
VStack(spacing: 16) {
|
|
ProgressView("Loading stats…")
|
|
Text("Stats appear after the first sync completes.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(.top, 60)
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle("Dashboard")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.refreshable {
|
|
await sync.fetchDashboardStats()
|
|
}
|
|
// Start cover for a tapped scheduled inspection. Owned here rather than
|
|
// by ScheduledInspectionsCard because that card self-hides the instant
|
|
// its last row is removed — which is exactly when this cover is on
|
|
// screen (submit deletes the cached schedule row). The ScrollView is
|
|
// always present, so the form is never torn down mid-submit.
|
|
.fullScreenCover(item: $scheduledStartTarget) { t in
|
|
StartInspectionView(
|
|
preFillTemplateId: t.templateServerId,
|
|
preFillFacilityId: t.facilityServerId,
|
|
preFillScheduleId: t.id,
|
|
preFillScheduleInstructions: t.instructions
|
|
)
|
|
}
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
@ViewBuilder
|
|
private func statsSection<Content: View>(
|
|
title: String,
|
|
@ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text(title.uppercased())
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.secondary)
|
|
.tracking(1)
|
|
content()
|
|
}
|
|
}
|
|
|
|
private func statTile(
|
|
value: String,
|
|
label: String,
|
|
icon: String,
|
|
color: Color
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.caption)
|
|
.foregroundStyle(color)
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
// Tiles are 2-up, so on a phone each is ~170 pt wide and
|
|
// longer labels ("Open / In Progress") would truncate.
|
|
.minimumScaleFactor(0.75)
|
|
}
|
|
Text(value)
|
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
|
.foregroundStyle(color)
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(color.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private func severityTile(count: Int, label: String, color: Color) -> some View {
|
|
VStack(spacing: 4) {
|
|
Text("\(count)")
|
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
|
.foregroundStyle(color)
|
|
Text(label)
|
|
.font(.caption2.bold())
|
|
.foregroundStyle(color.opacity(0.8))
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(color.opacity(0.08))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
}
|
|
|
|
// MARK: - Draft Resume Banner
|
|
// Shown on the dashboard when the inspector has one or more in-progress
|
|
// (draft) inspections. Tapping a draft opens ExecuteInspectionView as a
|
|
// full-screen sheet — avoids cross-NavigationStack linking since the
|
|
// dashboard and My Inspections stacks are independent.
|
|
|
|
struct DraftResumeBanner: View {
|
|
let drafts: [LocalInspection]
|
|
let context: ModelContext
|
|
@State private var selectedDraft: LocalInspection? = nil
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(drafts.count == 1
|
|
? "Inspection in progress"
|
|
: "\(drafts.count) inspections in progress",
|
|
systemImage: "pencil.and.list.clipboard")
|
|
.font(.subheadline.bold())
|
|
.foregroundStyle(.white)
|
|
|
|
ForEach(drafts) { draft in
|
|
Button {
|
|
selectedDraft = draft
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(templateName(for: draft))
|
|
.font(.callout.bold())
|
|
.foregroundStyle(.white)
|
|
Text(facilityName(for: draft))
|
|
.font(.caption)
|
|
.foregroundStyle(.white.opacity(0.85))
|
|
Text("Last saved \(draft.lastModifiedAt.formatted(.relative(presentation: .named)))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.white.opacity(0.70))
|
|
}
|
|
Spacer()
|
|
Label("Resume", systemImage: "play.fill")
|
|
.font(.caption.bold())
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(Color.white.opacity(0.25))
|
|
.clipShape(Capsule())
|
|
}
|
|
.padding(10)
|
|
.background(Color.white.opacity(0.12))
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(14)
|
|
.background(Color.blue.gradient)
|
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
|
.fullScreenCover(item: $selectedDraft) { draft in
|
|
// Wrap in NavigationStack so ExecuteInspectionView's toolbar
|
|
// and dismiss work correctly when presented as a sheet.
|
|
NavigationStack {
|
|
ExecuteInspectionView(inspection: draft, isModallyPresented: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func templateName(for inspection: LocalInspection) -> String {
|
|
let id = inspection.templateServerId
|
|
return (try? context.fetch(
|
|
FetchDescriptor<LocalTemplate>(predicate: #Predicate { $0.serverId == id })
|
|
).first?.name) ?? "Inspection"
|
|
}
|
|
|
|
private func facilityName(for inspection: LocalInspection) -> String {
|
|
let id = inspection.facilityServerId
|
|
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
|
return all.first(where: { $0.serverId == id })?.name ?? "Unknown Facility"
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Retryable Photo
|
|
|
|
/// Loads a server photo via AsyncImage with a tap-to-retry failure state.
|
|
/// 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 {
|
|
Group {
|
|
if let img = cached {
|
|
// Cache hit — instant display, no spinner, no network
|
|
Image(uiImage: img)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
} 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()
|
|
}
|
|
}
|
|
.id(reloadToken)
|
|
.onAppear {
|
|
// Check cache before AsyncImage fires a network request
|
|
if let url, let img = PhotoCache.shared.get(url) {
|
|
cached = img
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|