05/27 Update functionalities
This commit is contained in:
@@ -17,11 +17,13 @@ import Combine
|
||||
// MARK: - SidebarTab
|
||||
|
||||
enum SidebarTab: Hashable {
|
||||
case dashboard // landing page — KPI stats card
|
||||
case myInspections
|
||||
case issues // inspector role only
|
||||
case issues
|
||||
case facilities
|
||||
case pendingSync
|
||||
case history // moved after Pending Sync
|
||||
case history
|
||||
case notifications // in-app notification inbox
|
||||
case settings
|
||||
}
|
||||
|
||||
@@ -38,16 +40,18 @@ struct DashboardView: View {
|
||||
order: .reverse
|
||||
) private var myInspections: [LocalInspection]
|
||||
|
||||
@State private var selectedTab: SidebarTab = .myInspections
|
||||
@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(),
|
||||
]
|
||||
|
||||
@@ -69,6 +73,13 @@ struct DashboardView: View {
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
// ── Dashboard ──────────────────────────────────────────────
|
||||
Button { selectTab(.dashboard) } label: {
|
||||
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
||||
.foregroundStyle(selectedTab == .dashboard ? .blue : .primary)
|
||||
}
|
||||
.listRowBackground(selectedTab == .dashboard ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── My Inspections ─────────────────────────────────────────
|
||||
Button { selectTab(.myInspections) } label: {
|
||||
HStack {
|
||||
@@ -125,6 +136,27 @@ struct DashboardView: View {
|
||||
}
|
||||
.listRowBackground(selectedTab == .history ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────
|
||||
Button {
|
||||
selectTab(.notifications)
|
||||
sync.markNotificationsViewed()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Notifications", systemImage: "bell")
|
||||
.foregroundStyle(selectedTab == .notifications ? .blue : .primary)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(selectedTab == .notifications ? Color.blue.opacity(0.1) : Color.clear)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────
|
||||
Button { selectTab(.settings) } label: {
|
||||
Label("Settings", systemImage: "gear")
|
||||
@@ -138,6 +170,8 @@ struct DashboardView: View {
|
||||
|
||||
} detail: {
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
NavigationStack { DashboardStatsView() }
|
||||
case .myInspections:
|
||||
NavigationStack(path: $inspectionsPath) {
|
||||
MyInspectionsView()
|
||||
@@ -167,6 +201,8 @@ struct DashboardView: View {
|
||||
HistoryDetailView(inspection: inspection)
|
||||
}
|
||||
}
|
||||
case .notifications:
|
||||
NavigationStack { NotificationsView() }
|
||||
case .settings:
|
||||
NavigationStack { SettingsView() }
|
||||
}
|
||||
@@ -211,6 +247,204 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
}
|
||||
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: - My Inspections
|
||||
|
||||
struct MyInspectionsView: View {
|
||||
@@ -664,10 +898,17 @@ struct FacilitiesListView: View {
|
||||
|
||||
struct IssuesListView: View {
|
||||
|
||||
// Fetch all then filter in Swift — #Predicate with string literals on
|
||||
// LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION
|
||||
// (CLAUDE.md rule 3). Resolved issues are excluded to match the web default.
|
||||
@Query(
|
||||
sort: \LocalIssue.createdAt,
|
||||
order: .reverse
|
||||
) private var issues: [LocalIssue]
|
||||
) private var allIssues: [LocalIssue]
|
||||
|
||||
private var issues: [LocalIssue] {
|
||||
allIssues.filter { $0.issueStatus != "resolved" }
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var context
|
||||
@State private var showNewIssue = false
|
||||
@@ -707,10 +948,16 @@ struct IssueRowView: View {
|
||||
let context: ModelContext
|
||||
|
||||
private var facilityName: String {
|
||||
// Primary: look up from local reference cache (fast, works offline).
|
||||
// Fallback: facilityNameCache persisted from the last server sync.
|
||||
// This covers the case where the user cleared the local cache in Settings
|
||||
// while server-pulled issues are still present.
|
||||
let id = issue.facilityServerId
|
||||
return (try? context.fetch(
|
||||
FetchDescriptor<LocalFacility>(predicate: #Predicate { $0.serverId == id })
|
||||
).first?.name) ?? "Unknown Facility"
|
||||
let all = (try? context.fetch(FetchDescriptor<LocalFacility>())) ?? []
|
||||
if let name = all.first(where: { $0.serverId == id })?.name {
|
||||
return name
|
||||
}
|
||||
return issue.facilityNameCache ?? "Unknown Facility"
|
||||
}
|
||||
|
||||
private var severityColor: Color {
|
||||
@@ -722,6 +969,16 @@ struct IssueRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func issueStatusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "open": return .blue
|
||||
case "in_progress": return .orange
|
||||
case "pending_verification": return .purple
|
||||
case "resolved": return .green
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Circle()
|
||||
@@ -734,6 +991,12 @@ struct IssueRowView: View {
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(severityColor)
|
||||
Spacer()
|
||||
Text(issue.issueStatus.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(issueStatusColor(issue.issueStatus).opacity(0.15))
|
||||
.foregroundStyle(issueStatusColor(issue.issueStatus))
|
||||
.clipShape(Capsule())
|
||||
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
|
||||
}
|
||||
Text(issue.issueDescription)
|
||||
@@ -760,6 +1023,12 @@ struct IssueDetailView: View {
|
||||
@State private var isUpdatingStatus = false
|
||||
@State private var statusError: String?
|
||||
@State private var showStatusPicker = false
|
||||
// ── Comments ──────────────────────────────────────────────────────────
|
||||
@State private var comments: [APIIssueComment] = []
|
||||
@State private var isLoadingComments = false
|
||||
@State private var newCommentText = ""
|
||||
@State private var isPostingComment = false
|
||||
@State private var commentError: String?
|
||||
|
||||
private var facilityName: String {
|
||||
let id = issue.facilityServerId
|
||||
@@ -810,8 +1079,20 @@ struct IssueDetailView: View {
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
LabeledContent("Facility", value: facilityName)
|
||||
LabeledContent("Reported", value: issue.createdAt.formatted(
|
||||
if let area = issue.areaNameCache, !area.isEmpty {
|
||||
LabeledContent("Area", value: area)
|
||||
}
|
||||
if let assignee = issue.assignedToName, !assignee.isEmpty {
|
||||
LabeledContent("Assigned To", value: assignee)
|
||||
}
|
||||
// Use serverReportedAt when available — more accurate than
|
||||
// createdAt (device time) for server-pulled issues.
|
||||
let reportDate = issue.serverReportedAt ?? issue.createdAt
|
||||
LabeledContent("Reported", value: reportDate.formatted(
|
||||
date: .long, time: .shortened))
|
||||
if let reporter = issue.reportedByName, !reporter.isEmpty {
|
||||
LabeledContent("Reporter", value: reporter)
|
||||
}
|
||||
|
||||
// ── Issue Status ───────────────────────────────────────────
|
||||
LabeledContent("Issue Status") {
|
||||
@@ -859,6 +1140,28 @@ struct IssueDetailView: View {
|
||||
.font(.callout)
|
||||
}
|
||||
|
||||
// ── Resolution Details (web-staff only — read-only on iPad) ────
|
||||
if let notes = issue.resultNotes, !notes.isEmpty {
|
||||
Section("Resolution Notes") {
|
||||
Text(notes)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verification Details ───────────────────────────────────────
|
||||
if let vAt = issue.verifiedAt {
|
||||
Section("Verification") {
|
||||
LabeledContent("Verified", value: vAt.formatted(
|
||||
date: .long, time: .shortened))
|
||||
if let note = issue.verificationNote, !note.isEmpty {
|
||||
Text(note)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sync Status") {
|
||||
LabeledContent("Sync") {
|
||||
StatusBadge(status: issue.syncStatus, syncStatus: issue.syncStatus)
|
||||
@@ -906,11 +1209,63 @@ struct IssueDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────
|
||||
if sync.isOnline, issue.serverId != nil {
|
||||
if isLoadingComments {
|
||||
Section("Comments") {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
}
|
||||
} else if !comments.isEmpty {
|
||||
Section("Comments (\(comments.count))") {
|
||||
ForEach(comments) { comment in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(comment.authorName)
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
if let date = comment.createdAtDate {
|
||||
Text(date.formatted(.relative(presentation: .named)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
Text(comment.body)
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add comment ────────────────────────────────────────────
|
||||
Section("Add Comment") {
|
||||
TextEditor(text: $newCommentText)
|
||||
.frame(minHeight: 60)
|
||||
if let err = commentError {
|
||||
Text(err).font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
Button {
|
||||
Task { await postComment() }
|
||||
} label: {
|
||||
if isPostingComment {
|
||||
HStack { ProgressView(); Text("Posting…") }
|
||||
} else {
|
||||
Label("Post Comment", systemImage: "paperplane.fill")
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
newCommentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| isPostingComment
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Issue Detail")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await refreshStatusFromServer()
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,9 +1279,54 @@ struct IssueDetailView: View {
|
||||
do {
|
||||
let detail = try await APIClient.shared.fetchIssueDetail(issueId: sid)
|
||||
issue.issueStatus = detail.status
|
||||
// Refresh Phase A resolution fields from live server data
|
||||
if let notes = detail.resultNotes { issue.resultNotes = notes }
|
||||
if let vNote = detail.verificationNote { issue.verificationNote = vNote }
|
||||
if let rName = detail.reportedByName { issue.reportedByName = rName }
|
||||
if let fName = detail.facilityName, !fName.isEmpty {
|
||||
issue.facilityNameCache = fName
|
||||
}
|
||||
if let vts = detail.verifiedAt,
|
||||
let date = SyncManager.isoFormatter.date(from: vts) {
|
||||
issue.verifiedAt = date
|
||||
}
|
||||
if let area = detail.areaName, !area.isEmpty { issue.areaNameCache = area }
|
||||
if let assignee = detail.assignedToName, !assignee.isEmpty { issue.assignedToName = assignee }
|
||||
try? context.save()
|
||||
} catch {
|
||||
// Non-fatal — show cached status silently
|
||||
// Non-fatal — show cached values silently
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load comments from server ──────────────────────────────────────────
|
||||
|
||||
private func loadComments() async {
|
||||
guard sync.isOnline, let sid = issue.serverId else { return }
|
||||
isLoadingComments = true
|
||||
defer { isLoadingComments = false }
|
||||
do {
|
||||
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
|
||||
} catch {
|
||||
// Non-fatal — empty list shown
|
||||
}
|
||||
}
|
||||
|
||||
// ── Post a new comment ─────────────────────────────────────────────────
|
||||
|
||||
private func postComment() async {
|
||||
guard let sid = issue.serverId else { return }
|
||||
let body = newCommentText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !body.isEmpty else { return }
|
||||
isPostingComment = true
|
||||
commentError = nil
|
||||
defer { isPostingComment = false }
|
||||
do {
|
||||
_ = try await APIClient.shared.postIssueComment(issueId: sid, body: body)
|
||||
newCommentText = ""
|
||||
// Reload comments so the new one appears
|
||||
comments = try await APIClient.shared.fetchIssueComments(issueId: sid)
|
||||
} catch {
|
||||
commentError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1246,6 +1646,91 @@ struct StandaloneIssueView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notifications Inbox
|
||||
// Shows the most recent notifications fetched during polling.
|
||||
// Notifications are already marked read on the server by pollNotifications().
|
||||
|
||||
struct NotificationsView: View {
|
||||
|
||||
@EnvironmentObject private var sync: SyncManager
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if sync.recentNotifications.isEmpty {
|
||||
if !sync.isOnline {
|
||||
ContentUnavailableView(
|
||||
"Offline",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text("Notifications are delivered when you go online.")
|
||||
)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"No Notifications",
|
||||
systemImage: "bell.slash",
|
||||
description: Text("You\'re all caught up.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
List(sync.recentNotifications) { notif in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: iconName(for: notif.eventType))
|
||||
.foregroundStyle(iconColor(for: notif.eventType))
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(notif.title)
|
||||
.font(.callout.bold())
|
||||
.lineLimit(2)
|
||||
Text(notif.body)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
if let date = SyncManager.isoFormatter.date(from: notif.createdAt) {
|
||||
Text(date.formatted(.relative(presentation: .named)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
.refreshable {
|
||||
await sync.pollNotifications()
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
}
|
||||
|
||||
private func iconName(for eventType: String?) -> String {
|
||||
switch eventType {
|
||||
case "inspection_completed": return "checkmark.circle.fill"
|
||||
case "issue_flagged": return "exclamationmark.triangle.fill"
|
||||
case "issue_resolved": return "checkmark.seal.fill"
|
||||
case "sla_alert": return "clock.badge.exclamationmark"
|
||||
case "follow_up_required": return "exclamationmark.arrow.circlepath"
|
||||
default: return "bell.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func iconColor(for eventType: String?) -> Color {
|
||||
switch eventType {
|
||||
case "inspection_completed": return .green
|
||||
case "issue_flagged": return .orange
|
||||
case "issue_resolved": return .green
|
||||
case "sla_alert": return .red
|
||||
case "follow_up_required": return .orange
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Retryable Photo
|
||||
|
||||
/// Loads a server photo via AsyncImage with a tap-to-retry failure state.
|
||||
|
||||
Reference in New Issue
Block a user