// Views/Dashboard/NotificationsView.swift // --------------------------------------- // In-app notification inbox, backed by LocalNotification (see that file for why // the inbox is stored locally rather than re-read from the server each time). // // Every row used to look identical because the poll endpoint only returns // UNREAD notifications — the list was, by construction, all-unread with nothing // to distinguish. Now read state is real: unread rows carry a dot and a bold // title, read rows are muted, and reading is an explicit act (tap a row, or // Mark All Read) rather than a side effect of opening the screen. import SwiftUI import SwiftData import MessageUI // MARK: - Inbox struct NotificationsView: View { @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context // Sorted newest-first. Filtered in Swift, not in the @Query predicate // (CLAUDE.md rule 3). @Query(sort: \LocalNotification.createdAt, order: .reverse) private var allNotifications: [LocalNotification] enum Filter: String, CaseIterable, Identifiable { case all, unread, read var id: String { rawValue } var label: String { switch self { case .all: return "All" case .unread: return "Unread" case .read: return "Read" } } } @State private var filter: Filter = .all @State private var isMarkingAll = false private var unreadCount: Int { allNotifications.filter { !$0.isRead }.count } private var visible: [LocalNotification] { switch filter { case .all: return allNotifications case .unread: return allNotifications.filter { !$0.isRead } case .read: return allNotifications.filter { $0.isRead } } } var body: some View { Group { if allNotifications.isEmpty { emptyState } else { VStack(spacing: 0) { // OUTSIDE the List, so it survives a filter that matches // nothing. As a list row it vanished with the rows — // selecting "Read" with nothing read left no way back. Picker("Show", selection: $filter) { ForEach(Filter.allCases) { f in Text(f.label).tag(f) } } .pickerStyle(.segmented) .padding(.horizontal, 16) .padding(.vertical, 8) if visible.isEmpty { ContentUnavailableView( filter == .unread ? "All Caught Up" : "Nothing Read Yet", systemImage: filter == .unread ? "checkmark.circle" : "envelope.open", description: Text(filter == .unread ? "You have no unread notifications." : "Notifications you open will appear here.") ) Spacer(minLength: 0) } else { List { ForEach(visible) { notif in NavigationLink(value: notif) { NotificationRow(notification: notif) } .swipeActions(edge: .leading, allowsFullSwipe: true) { if !notif.isRead { Button { Task { await sync.markNotificationRead(notif) } } label: { Label("Read", systemImage: "envelope.open") } .tint(.blue) } } } } } } } } // On the ALWAYS-PRESENT Group, never inside the List — same hazard as // rule 68. Opening a notification marks it read, which removes it from // the "Unread" filter; if that was the last row, the List is replaced by // an empty state and a destination declared inside it would be torn // down, popping the detail view out from under the inspector as they // read it. .navigationDestination(for: LocalNotification.self) { notif in NotificationDetailView(notification: notif) } .navigationTitle(unreadCount > 0 ? "Notifications (\(unreadCount))" : "Notifications") .navigationBarTitleDisplayMode(.large) .toolbar { if unreadCount > 0 { ToolbarItem(placement: .primaryAction) { Button { Task { isMarkingAll = true await sync.markAllNotificationsRead() isMarkingAll = false } } label: { if isMarkingAll { ProgressView() } else { Label("Mark All Read", systemImage: "envelope.open") } } .labelStyle(.titleAndIcon) // rule 72 .disabled(isMarkingAll) } } } .refreshable { await sync.pollNotifications() } // Keep the sidebar badge honest if read state changed elsewhere (a // swipe, the detail view, or a push that landed while this was open). .onAppear { sync.refreshUnreadNotificationCount() } } @ViewBuilder private var emptyState: some View { 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.") ) } } } // MARK: - Row struct NotificationRow: View { let notification: LocalNotification var body: some View { HStack(alignment: .top, spacing: 10) { // Unread marker. A filled dot rather than colour alone, so the // distinction survives greyscale and colour-blind vision. Circle() .fill(notification.isRead ? Color.clear : Color.blue) .frame(width: 8, height: 8) .padding(.top, 6) Image(systemName: NotificationStyle.icon(for: notification.eventType)) .foregroundStyle(notification.isRead ? Color.secondary : NotificationStyle.color(for: notification.eventType)) .frame(width: 24) .padding(.top, 2) VStack(alignment: .leading, spacing: 3) { Text(notification.title) .font(notification.isRead ? .callout : .callout.bold()) .foregroundStyle(notification.isRead ? .secondary : .primary) .lineLimit(2) Text(notification.body) .font(.caption) .foregroundStyle(notification.isRead ? Color(.tertiaryLabel) : .secondary) .lineLimit(2) Text(notification.createdAt.formatted(.relative(presentation: .named))) .font(.caption2) .foregroundStyle(.tertiary) } } .padding(.vertical, 4) } } // MARK: - Detail /// Full text of one notification, plus a route to whatever it refers to. /// /// Opening this marks the notification read — the standard inbox contract, and /// the reason a tap is treated as a deliberate read action that also clears the /// user's web badge (rule 92). struct NotificationDetailView: View { let notification: LocalNotification @EnvironmentObject private var sync: SyncManager @Environment(\.modelContext) private var context /// The issue this notification refers to, when it refers to one AND that /// issue is cached on this device. Absent is normal, not an error: the /// issue may belong to another inspector, or simply not be pulled yet. private var linkedIssue: LocalIssue? { guard let issueId = notification.issueId else { return nil } // Fetch-all + filter in Swift (rule 3), `try?` parenthesised (rule 25). let all = (try? context.fetch(FetchDescriptor())) ?? [] return all.first { $0.serverId == issueId } } var body: some View { List { Section { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: NotificationStyle.icon(for: notification.eventType)) .foregroundStyle(NotificationStyle.color(for: notification.eventType)) Text(NotificationStyle.label(for: notification.eventType)) .font(.caption.bold()) .foregroundStyle(.secondary) } Text(notification.title) .font(.headline) Text(notification.body) .font(.callout) .fixedSize(horizontal: false, vertical: true) } .padding(.vertical, 4) } Section("Received") { LabeledContent("Sent", value: notification.createdAt.formatted(date: .long, time: .shortened)) if notification.isRead, let readAt = notification.readAt { LabeledContent("Read", value: readAt.formatted(date: .long, time: .shortened)) } } if let issueId = notification.issueId { Section("Related") { if let issue = linkedIssue { NavigationLink(value: issue) { Label("View Issue #\(issueId)", systemImage: "exclamationmark.triangle") } } else { // Honest dead end rather than a link that goes nowhere. Label("Issue #\(issueId) is not on this device yet.", systemImage: "arrow.down.circle") .font(.callout) .foregroundStyle(.secondary) Text("It will appear under Issues after the next sync, " + "if it is assigned to you.") .font(.caption) .foregroundStyle(.tertiary) } } } if !notification.isRead { Section { Button { Task { await sync.markNotificationRead(notification) } } label: { Label("Mark as Read", systemImage: "envelope.open") } } } } .navigationTitle("Notification") .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: LocalIssue.self) { issue in IssueDetailView(issue: issue) } .task { // Opening IS reading — the standard inbox contract. // Re-fires when returning from the issue detail, which is harmless: // markNotificationRead() no-ops once isRead is true. await sync.markNotificationRead(notification) } } } // MARK: - Event styling /// Icon, colour and human label per server `event_type`. /// /// Keys mirror the constants in `app/models/notification.py`; an unknown or nil /// type (rows predating the server's phase17 migration) falls back to a /// neutral bell rather than being hidden. nonisolated enum NotificationStyle { static func icon(for eventType: String?) -> String { switch eventType { case "issue_assigned": return "person.crop.circle.badge.exclamationmark" case "issue_status": return "arrow.triangle.2.circlepath" case "issue_comment": return "text.bubble" case "issue_flagged": return "exclamationmark.triangle.fill" case "issue_follow_update": return "bell.badge" case "inspection_completed": return "checkmark.circle.fill" case "sla_alert": return "clock.badge.exclamationmark" case "score_alert": return "chart.line.downtrend.xyaxis" case "scheduled_inspection": return "calendar.badge.clock" case "followup_requested": return "exclamationmark.arrow.circlepath" case "admin_broadcast": return "megaphone" default: return "bell.fill" } } static func color(for eventType: String?) -> Color { switch eventType { case "issue_assigned": return .blue case "issue_status": return .blue case "issue_comment": return .teal case "issue_flagged": return .orange case "issue_follow_update": return .blue case "inspection_completed": return .green case "sla_alert": return .red case "score_alert": return .red case "scheduled_inspection": return .indigo case "followup_requested": return .orange case "admin_broadcast": return .purple default: return .blue } } static func label(for eventType: String?) -> String { switch eventType { case "issue_assigned": return "ISSUE ASSIGNED" case "issue_status": return "ISSUE STATUS" case "issue_comment": return "NEW COMMENT" case "issue_flagged": return "ISSUE FLAGGED" case "issue_follow_update": return "FOLLOWED ISSUE" case "inspection_completed": return "INSPECTION COMPLETED" case "sla_alert": return "SLA ALERT" case "score_alert": return "SCORE ALERT" case "scheduled_inspection": return "SCHEDULED INSPECTION" case "followup_requested": return "FOLLOW-UP REQUESTED" case "admin_broadcast": return "ANNOUNCEMENT" default: return "NOTIFICATION" } } }