Aug 27 - Fixed inspection lost photos recovery
This commit is contained in:
@@ -1,90 +1,355 @@
|
||||
// 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: - Notifications Inbox
|
||||
// Shows the most recent notifications fetched during polling.
|
||||
// Notifications are already marked read on the server by pollNotifications().
|
||||
// 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 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.")
|
||||
)
|
||||
}
|
||||
if allNotifications.isEmpty {
|
||||
emptyState
|
||||
} 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Notifications")
|
||||
// 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)
|
||||
.onAppear {
|
||||
sync.markNotificationsViewed()
|
||||
.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()
|
||||
sync.markNotificationsViewed()
|
||||
}
|
||||
// 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() }
|
||||
}
|
||||
|
||||
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
|
||||
@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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user