Files

356 lines
15 KiB
Swift

// 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<LocalIssue>())) ?? []
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"
}
}
}