AnnouncementCenter.swift (10533B)
1 import Foundation 2 import Observation 3 4 /// One-shot status message surfaced in a banner area — the puzzle header 5 /// when scoped to a game, the game list when global. Designed to host both 6 /// info-class summaries (e.g. "Alice added 4 letters") 7 /// and error-class failures (e.g. "Couldn't accept invite") that previously 8 /// went through modal alerts. 9 struct Announcement: Identifiable, Equatable, Sendable { 10 /// Severity of an announcement, used both for visual treatment and for 11 /// pick-the-winner logic when two announcements compete for the same 12 /// surface — higher-severity displaces lower. 13 enum Severity: Int, Comparable, Sendable { 14 /// Lowest severity: onboarding tips. Ranked below `info` so a tip never 15 /// displaces a real status message and is itself displaced by one. 16 case tip 17 case info 18 case warning 19 case error 20 21 static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rawValue < rhs.rawValue } 22 } 23 24 /// Dismissal behavior. `.transient` auto-clears after the given delay; 25 /// `.manual` stays until the user taps it away; `.sticky` requires 26 /// programmatic dismissal (the producer must call `dismiss(id:)` 27 /// itself). `.sticky` is the only kind that pairs sensibly with 28 /// `blocksInput`. 29 enum Dismissal: Equatable, Sendable { 30 case transient(after: TimeInterval) 31 case manual 32 case sticky 33 } 34 35 /// Surface scope. Game-scoped announcements take priority over global 36 /// ones at the puzzle header; global-only ones surface on the game 37 /// list. A producer that has a relevant `gameID` should prefer 38 /// `.game(_)` so the announcement only appears where it makes sense. 39 enum Scope: Hashable, Sendable { 40 case global 41 case game(UUID) 42 } 43 44 /// Stable id; reposting with the same id replaces the prior 45 /// announcement in place rather than queueing another behind it. 46 let id: String 47 let scope: Scope 48 let severity: Severity 49 let title: String? 50 let body: String 51 let dismissal: Dismissal 52 /// When true, the puzzle's input layer (custom keyboard + hardware key 53 /// handler) is greyed out and ignores input for as long as this 54 /// announcement is showing. Only sensible alongside `.sticky`. 55 let blocksInput: Bool 56 let createdAt: Date 57 58 init( 59 id: String, 60 scope: Scope, 61 severity: Severity, 62 title: String? = nil, 63 body: String, 64 dismissal: Dismissal, 65 blocksInput: Bool = false, 66 createdAt: Date = Date() 67 ) { 68 self.id = id 69 self.scope = scope 70 self.severity = severity 71 self.title = title 72 self.body = body 73 self.dismissal = dismissal 74 self.blocksInput = blocksInput 75 self.createdAt = createdAt 76 } 77 } 78 79 extension Announcement { 80 /// The sticky, input-blocking banner shown when a shared puzzle's 81 /// owner revokes the local user's access. Folds the former bespoke 82 /// `AccessRevokedBanner` overlay into the announcement system, so the 83 /// revoked puzzle's keyboard and hardware keys grey out for as long 84 /// as the banner shows. 85 static func accessRevoked(gameID: UUID) -> Announcement { 86 Announcement( 87 id: "access-revoked-\(gameID.uuidString)", 88 scope: .game(gameID), 89 severity: .error, 90 title: "Puzzle Not Shared", 91 body: "This puzzle is no longer shared with you.", 92 dismissal: .sticky, 93 blocksInput: true 94 ) 95 } 96 97 /// The sticky, input-blocking banner shown when the open puzzle's game is 98 /// hard-deleted out from under it — a solo puzzle's private zone vanished, 99 /// or a shared puzzle was left on another device. Game-scoped, so it only 100 /// surfaces in that puzzle's header, never the game list; the caller posts 101 /// it only when the removed game is the one on screen. The puzzle stays 102 /// open and frozen until the user backs out — by then it is already gone 103 /// from the list. 104 static func gameRemoved(gameID: UUID) -> Announcement { 105 Announcement( 106 id: "game-removed-\(gameID.uuidString)", 107 scope: .game(gameID), 108 severity: .error, 109 title: "Puzzle Removed", 110 body: "This puzzle was removed.", 111 dismissal: .sticky, 112 blocksInput: true 113 ) 114 } 115 116 /// Reassurance shown on the Game List when a share was accepted but its 117 /// puzzle had not finished syncing before the join wait timed out. The game 118 /// surfaces on its own once sync settles, so this clears itself rather than 119 /// asking the user to act. 120 static func puzzleStillSyncing() -> Announcement { 121 Announcement( 122 id: "share-join-pending-sync", 123 scope: .global, 124 severity: .info, 125 title: "Puzzle Unavailable", 126 body: "This puzzle is still syncing and will appear in your list shortly.", 127 dismissal: .transient(after: 6) 128 ) 129 } 130 } 131 132 /// The persisted, open-relevant facts about a game — the input to 133 /// `OpenPuzzleBanner.announcements(for:)`. Grouped into a value so the 134 /// reconciler can be unit-tested without standing up a `GameMutator`. 135 struct OpenPuzzleState { 136 let gameID: UUID 137 let isAccessRevoked: Bool 138 } 139 140 /// A banner that may be (re)posted when a puzzle is opened, reconciled from 141 /// *persisted* game state rather than a live sync transition. Each such 142 /// banner is otherwise posted only on the event that first produces it, into 143 /// an in-memory `AnnouncementCenter` that does not survive a process restart 144 /// — so a puzzle opened in a later process would show nothing. Extend by 145 /// adding a case and its switch arm. 146 enum OpenPuzzleBanner: CaseIterable { 147 case accessRevoked 148 149 /// The announcement this banner contributes for `state`, or `nil` when 150 /// the state does not warrant it. 151 func announcement(for state: OpenPuzzleState) -> Announcement? { 152 switch self { 153 case .accessRevoked: 154 state.isAccessRevoked ? .accessRevoked(gameID: state.gameID) : nil 155 } 156 } 157 158 /// Every banner to (re)post for a puzzle opened in `state`. The caller 159 /// posts each one; `AnnouncementCenter.post` is idempotent by id, so 160 /// re-posting a banner that is already showing is a no-op. 161 static func announcements(for state: OpenPuzzleState) -> [Announcement] { 162 allCases.compactMap { $0.announcement(for: state) } 163 } 164 } 165 166 /// Single source of truth for transient banner-style status messages. Holds 167 /// at most one announcement per scope; reposting with the same id replaces 168 /// the prior copy. Surfaces (PuzzleHeader, GameListView) read via the 169 /// scope-specific accessors and observe via `@Observable`. 170 @MainActor 171 @Observable 172 final class AnnouncementCenter { 173 /// All active announcements, keyed by id. Kept private so callers go 174 /// through `current(forGame:)` / `currentGlobal()` and inherit the 175 /// scope-precedence rule (game > global) consistently. 176 private var byId: [String: Announcement] = [:] 177 /// Auto-dismiss tasks for `.transient` announcements; cancelled when 178 /// the announcement is replaced or dismissed early so we don't fire a 179 /// stale dismissal against a fresh announcement that happens to share 180 /// the id. 181 private var dismissalTasks: [String: Task<Void, Never>] = [:] 182 /// Sleep primitive used by transient auto-dismiss timers. Injected so 183 /// tests can drive expiry deterministically instead of racing wall-clock 184 /// `Task.sleep` on a contended simulator. 185 private let sleep: @Sendable (Duration) async throws -> Void 186 187 init( 188 sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } 189 ) { 190 self.sleep = sleep 191 } 192 193 func post(_ announcement: Announcement) { 194 if let existing = dismissalTasks.removeValue(forKey: announcement.id) { 195 existing.cancel() 196 } 197 byId[announcement.id] = announcement 198 if case let .transient(after) = announcement.dismissal { 199 let id = announcement.id 200 let sleep = self.sleep 201 dismissalTasks[id] = Task { @MainActor [weak self] in 202 try? await sleep(.seconds(after)) 203 guard !Task.isCancelled else { return } 204 self?.autoDismiss(id: id) 205 } 206 } 207 } 208 209 func dismiss(id: String) { 210 byId.removeValue(forKey: id) 211 if let task = dismissalTasks.removeValue(forKey: id) { 212 task.cancel() 213 } 214 } 215 216 /// Topmost announcement to display in the puzzle header for `gameID`. 217 /// Prefers game-scoped over global so a puzzle-relevant message isn't 218 /// hidden behind an app-wide one; ties broken by severity, then by 219 /// createdAt (newest wins). Tips are excluded entirely: they're a Game 220 /// List-only affordance and must never surface over the puzzle grid. 221 func current(forGame gameID: UUID) -> Announcement? { 222 let gameScoped = byId.values.filter { $0.scope == .game(gameID) } 223 if let pick = pick(from: gameScoped) { return pick } 224 let global = currentGlobal() 225 return global?.severity == .tip ? nil : global 226 } 227 228 /// Topmost global announcement (used by surfaces that have no specific 229 /// game context, like the game list). 230 func currentGlobal() -> Announcement? { 231 pick(from: byId.values.filter { $0.scope == .global }) 232 } 233 234 /// Whether any currently-showing announcement for `gameID` flags input 235 /// as blocked. Drives the greyed-out keyboard + hardware-key gating. 236 func isInputBlocked(forGame gameID: UUID) -> Bool { 237 current(forGame: gameID)?.blocksInput == true 238 } 239 240 private func pick(from candidates: some Collection<Announcement>) -> Announcement? { 241 candidates.max { lhs, rhs in 242 if lhs.severity != rhs.severity { return lhs.severity < rhs.severity } 243 return lhs.createdAt < rhs.createdAt 244 } 245 } 246 247 private func autoDismiss(id: String) { 248 // Re-check before removing: a later `post(_:)` may have superseded 249 // this announcement with a non-transient one under the same id. 250 guard let active = byId[id], 251 case .transient = active.dismissal 252 else { 253 dismissalTasks.removeValue(forKey: id) 254 return 255 } 256 byId.removeValue(forKey: id) 257 dismissalTasks.removeValue(forKey: id) 258 } 259 }