crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit fe2d271340a3bee69cf373e436a5dfddd3a63ab4
parent b1744e82ed6b3914dfcd369edf71d21b6fcb07af
Author: Michael Camilleri <[email protected]>
Date:   Thu, 21 May 2026 09:53:04 +0900

Add in-puzzle announcement banner for session-end summaries

This commit adds an in-puzzle counterpart to the end-of-session notification
path: when the user opens a puzzle, any tally SessionMonitor was holding for
that game is consumed straight into a banner on the puzzle header rather than
fired as a local notification a few minutes later. Out-of-puzzle activity still
becomes a UNNotificationRequest as before; the banner is just the in-puzzle
surface for the same information. The hand-off lives in
AppServices.handlePuzzleOpened(gameID:), called from CrossmateApp when
setActivePuzzleID fires, and replaces the bare sessionMonitor.cancel(...) that
used to throw the tally away on open.

The new surface is AnnouncementCenter, an @Observable posted into via
services.announcements and observed via @Environment. Each Announcement carries
a stable ID (re-posting replaces in place), severity, dismissal behaviour,
scope (.global / .game(UUID)), and a blocksInput flag reserved for the
input-blocking errors that is intended to land in a later stage.
SessionMonitor gains consumeOnOpen(gameID:) to drain its buckets, hydrate them
with player + puzzle names, and withdraw the matching session-end-...
notifications in one pass.

PuzzleHeader hosts the banner alongside its title/scoreboard/credits TabView in
the same fixed-height slot. The title is the baseline and renders immediately
on open; a 750-millisecond hold lets the puzzle settle before any banner
animates in via a .move(edge: .bottom) + opacity transition.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 16++++++++++++++++
MCrossmate/CrossmateApp.swift | 9+++++----
ACrossmate/Services/AnnouncementCenter.swift | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 38++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/SessionMonitor.swift | 44++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/AnnouncementBanner.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/PuzzleView.swift | 48+++++++++++++++++++++++++++++++++++++++++++++---
ATests/Unit/AnnouncementCenterTests.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/Unit/Sync/AppServicesAnnouncementTests.swift | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/Sync/SessionMonitorTests.swift | 39+++++++++++++++++++++++++++++++++++++++
10 files changed, 571 insertions(+), 7 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -41,9 +41,11 @@ 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; 5ECF5B80D08E5E999A540782 /* SessionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EB332831AB173ACF6BFEC59 /* SessionMonitor.swift */; }; + 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */; }; 6A1CA96FF48CBEEE78EA6D34 /* FriendModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B766E872B12DC79ECCD80941 /* FriendModelTests.swift */; }; 6AE88D9E1918508DBF2A91E1 /* NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2FD896D75863554E31654C /* NotificationState.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; + 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */; }; 712A2764596A2D17A0BBBF3B /* FriendZoneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800CCFBE90554F287E765755 /* FriendZoneTests.swift */; }; 740F5EC3331CA9DCCDA682F0 /* SuccessPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B23A692318044351247606DF /* SuccessPanel.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; @@ -73,6 +75,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF633D73818BD59F759FAC4 /* AboutView.swift */; }; + AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */; }; AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B6AB531F4E0C4031B627C539 /* PlayerSelectionPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11BF168D5C1CD85DAE5CAF9E /* PlayerSelectionPublisher.swift */; }; @@ -97,6 +100,7 @@ D13ECFAE05DB508577D2FF66 /* RecordBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5267DDA1A330DCBD07303D44 /* RecordBuilder.swift */; }; D219A9ACC7C1FB305DA6A4CE /* NYTLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C57DEE9E0EFA684D8BD00B /* NYTLoginView.swift */; }; D5150033DB80810F93BE0B5F /* RecordEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30C592ECAF9B51BC7F1D297 /* RecordEditorView.swift */; }; + D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */; }; D58980B92C99122C368D4216 /* GameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93EE5BA78566EDED68D846AB /* GameStore.swift */; }; D94FF5DFB9412D2DC24F6574 /* RecordApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD63A9B20168F3B81AF4348F /* RecordApplier.swift */; }; DE2F9B91A6A68594491182E3 /* NewGameSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */; }; @@ -138,6 +142,7 @@ 16AAC1E8D2CB3B5117159934 /* CloudQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudQuery.swift; sourceTree = "<group>"; }; 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebuggingMonitors.swift; sourceTree = "<group>"; }; 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerRosterTests.swift; sourceTree = "<group>"; }; + 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenter.swift; sourceTree = "<group>"; }; 1F2BE43E18B1CC6AAD27DC6D /* NYTBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTBrowseView.swift; sourceTree = "<group>"; }; 20B331CC55827FEF3420ABCE /* PlayerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSession.swift; sourceTree = "<group>"; }; 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorDeltaTests.swift; sourceTree = "<group>"; }; @@ -167,6 +172,7 @@ 5C74683332956B0D1CA37589 /* ShareController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareController.swift; sourceTree = "<group>"; }; 5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerNamePublisherTests.swift; sourceTree = "<group>"; }; 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCursorStoreTests.swift; sourceTree = "<group>"; }; + 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementBanner.swift; sourceTree = "<group>"; }; 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; }; 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; @@ -190,6 +196,8 @@ 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnsureGameEntityTests.swift; sourceTree = "<group>"; }; + 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCenterTests.swift; sourceTree = "<group>"; }; + 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServicesAnnouncementTests.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; @@ -288,6 +296,7 @@ 212DB6FCF46C41F81C41D232 /* Unit */ = { isa = PBXGroup; children = ( + 978A96CC6F550ED7A73F8D96 /* AnnouncementCenterTests.swift */, 60E818B0F4689BAD57660B7C /* GameCursorStoreTests.swift */, BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */, 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */, @@ -375,6 +384,7 @@ isa = PBXGroup; children = ( 4AF633D73818BD59F759FAC4 /* AboutView.swift */, + 61687BB3C75A4E1E6ABC82CA /* AnnouncementBanner.swift */, 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */, F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, @@ -411,6 +421,7 @@ ABB371EF2574E95782CB05FD /* Sync */ = { isa = PBXGroup; children = ( + 9998739ED0875A17271B7899 /* AppServicesAnnouncementTests.swift */, 2570F892610F1834C92F7F6E /* AuthorDeltaTests.swift */, 457B06DBFDC358D213A7CE54 /* AuthorIdentityTests.swift */, 94CEBA27A8AC4FCC92ADE1B4 /* EnsureGameEntityTests.swift */, @@ -442,6 +453,7 @@ D8F0E3376B2616B4E917129C /* Services */ = { isa = PBXGroup; children = ( + 1D3ECD0DE71BE567BCEE15F6 /* AnnouncementCenter.swift */, CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */, 56BC76178319D0D669CD50FF /* CloudService.swift */, 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */, @@ -553,6 +565,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 6C091D30AAC9F63B7CE6FB58 /* AnnouncementCenterTests.swift in Sources */, + D519F54F8CE0BD53D9C6144C /* AppServicesAnnouncementTests.swift in Sources */, C2D8A9C79D75DBEF45720927 /* AuthorDeltaTests.swift in Sources */, A98382E7659991FAF0F4ED0A /* AuthorIdentityTests.swift in Sources */, 02943BA53D2130B910E6DC00 /* EnsureGameEntityTests.swift in Sources */, @@ -593,6 +607,8 @@ buildActionMask = 2147483647; files = ( AB05765D2C3F4841026344E5 /* AboutView.swift in Sources */, + AD50D3E3401C7BF9ED012768 /* AnnouncementBanner.swift in Sources */, + 5EFCD28B3B682DCCF38068D6 /* AnnouncementCenter.swift in Sources */, 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */, 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */, AA28425BD26F72A9E2B58742 /* BundledBrowseView.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -21,6 +21,7 @@ struct CrossmateApp: App { .environment(\.managedObjectContext, services.persistence.viewContext) .environment(services.driveMonitor) .environment(services.inputMonitor) + .environment(services.announcements) .environment(services.syncMonitor) .environment(\.syncEngine, services.syncEngine) .environment(services.nytAuth) @@ -544,11 +545,11 @@ private struct PuzzleDisplayView: View { private func updateActiveNotificationPuzzleID(for phase: ScenePhase) { if phase == .active { NotificationState.setActivePuzzleID(gameID) - // Opening the puzzle supersedes any pending end-of-session - // summary — the user is about to see the moves directly. + // Hand any pending end-of-session tallies off to the + // in-puzzle banner instead of letting the local notification + // fire a few minutes from now. let id = gameID - let sessionMonitor = services.sessionMonitor - Task { await sessionMonitor.cancel(gameID: id) } + Task { await services.handlePuzzleOpened(gameID: id) } } else { NotificationState.clearActivePuzzleID(if: gameID) } diff --git a/Crossmate/Services/AnnouncementCenter.swift b/Crossmate/Services/AnnouncementCenter.swift @@ -0,0 +1,158 @@ +import Foundation +import Observation + +/// One-shot status message surfaced in a banner area — the puzzle header +/// when scoped to a game, the game list when global. Designed to host both +/// info-class summaries (e.g. "Alice added 4 letters while you were away") +/// and error-class failures (e.g. "Couldn't accept invite") that previously +/// went through modal alerts. +struct Announcement: Identifiable, Equatable, Sendable { + /// Severity of an announcement, used both for visual treatment and for + /// pick-the-winner logic when two announcements compete for the same + /// surface — higher-severity displaces lower. + enum Severity: Int, Comparable, Sendable { + case info + case warning + case error + + static func < (lhs: Severity, rhs: Severity) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// Dismissal behavior. `.transient` auto-clears after the given delay; + /// `.manual` stays until the user taps it away; `.sticky` requires + /// programmatic dismissal (the producer must call `dismiss(id:)` + /// itself). `.sticky` is the only kind that pairs sensibly with + /// `blocksInput`. + enum Dismissal: Equatable, Sendable { + case transient(after: TimeInterval) + case manual + case sticky + } + + /// Surface scope. Game-scoped announcements take priority over global + /// ones at the puzzle header; global-only ones surface on the game + /// list. A producer that has a relevant `gameID` should prefer + /// `.game(_)` so the announcement only appears where it makes sense. + enum Scope: Hashable, Sendable { + case global + case game(UUID) + } + + /// Stable id; reposting with the same id replaces the prior + /// announcement in place rather than queueing another behind it. + let id: String + let scope: Scope + let severity: Severity + let title: String? + let body: String + let dismissal: Dismissal + /// When true, the puzzle's input layer (custom keyboard + hardware key + /// handler) is greyed out and ignores input for as long as this + /// announcement is showing. Only sensible alongside `.sticky`. + let blocksInput: Bool + let createdAt: Date + + init( + id: String, + scope: Scope, + severity: Severity, + title: String? = nil, + body: String, + dismissal: Dismissal, + blocksInput: Bool = false, + createdAt: Date = Date() + ) { + self.id = id + self.scope = scope + self.severity = severity + self.title = title + self.body = body + self.dismissal = dismissal + self.blocksInput = blocksInput + self.createdAt = createdAt + } +} + +/// Single source of truth for transient banner-style status messages. Holds +/// at most one announcement per scope; reposting with the same id replaces +/// the prior copy. Surfaces (PuzzleHeader, GameListView) read via the +/// scope-specific accessors and observe via `@Observable`. +@MainActor +@Observable +final class AnnouncementCenter { + /// All active announcements, keyed by id. Kept private so callers go + /// through `current(forGame:)` / `currentGlobal()` and inherit the + /// scope-precedence rule (game > global) consistently. + private var byId: [String: Announcement] = [:] + /// Auto-dismiss tasks for `.transient` announcements; cancelled when + /// the announcement is replaced or dismissed early so we don't fire a + /// stale dismissal against a fresh announcement that happens to share + /// the id. + private var dismissalTasks: [String: Task<Void, Never>] = [:] + + init() {} + + func post(_ announcement: Announcement) { + if let existing = dismissalTasks.removeValue(forKey: announcement.id) { + existing.cancel() + } + byId[announcement.id] = announcement + if case let .transient(after) = announcement.dismissal { + let id = announcement.id + dismissalTasks[id] = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(after)) + guard !Task.isCancelled else { return } + self?.autoDismiss(id: id) + } + } + } + + func dismiss(id: String) { + byId.removeValue(forKey: id) + if let task = dismissalTasks.removeValue(forKey: id) { + task.cancel() + } + } + + /// Topmost announcement to display in the puzzle header for `gameID`. + /// Prefers game-scoped over global so a puzzle-relevant message isn't + /// hidden behind an app-wide one; ties broken by severity, then by + /// createdAt (newest wins). + func current(forGame gameID: UUID) -> Announcement? { + let gameScoped = byId.values.filter { $0.scope == .game(gameID) } + if let pick = pick(from: gameScoped) { return pick } + return currentGlobal() + } + + /// Topmost global announcement (used by surfaces that have no specific + /// game context, like the game list). + func currentGlobal() -> Announcement? { + pick(from: byId.values.filter { $0.scope == .global }) + } + + /// Whether any currently-showing announcement for `gameID` flags input + /// as blocked. Drives the greyed-out keyboard + hardware-key gating. + func isInputBlocked(forGame gameID: UUID) -> Bool { + current(forGame: gameID)?.blocksInput == true + } + + private func pick(from candidates: some Collection<Announcement>) -> Announcement? { + candidates.max { lhs, rhs in + if lhs.severity != rhs.severity { return lhs.severity < rhs.severity } + return lhs.createdAt < rhs.createdAt + } + } + + private func autoDismiss(id: String) { + // Re-check before removing: a later `post(_:)` may have superseded + // this announcement with a non-transient one under the same id. + guard let active = byId[id], + case .transient = active.dismissal + else { + dismissalTasks.removeValue(forKey: id) + return + } + byId.removeValue(forKey: id) + dismissalTasks.removeValue(forKey: id) + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -52,6 +52,7 @@ final class AppServices { let inputMonitor: InputMonitor let movesUpdater: MovesUpdater let sessionMonitor: SessionMonitor + let announcements: AnnouncementCenter let playerSelectionPublisher: PlayerSelectionPublisher let identity: AuthorIdentity let shareController: ShareController @@ -152,6 +153,8 @@ final class AppServices { localAuthorIDProvider: { await MainActor.run { identity.currentID } } ) + self.announcements = AnnouncementCenter() + let cursorStore = GameCursorStore() self.cursorStore = cursorStore let onGameDeletedHandler = Self.makeOnGameDeleted( @@ -1418,6 +1421,41 @@ final class AppServices { } } + /// Hand-off called when the puzzle becomes active. Pulls any pending + /// session-end tallies out of SessionMonitor and posts them as a + /// transient announcement on the puzzle header, in lieu of the + /// local notification that would otherwise have fired in a few + /// minutes' time. No-op if nothing was accumulated. + func handlePuzzleOpened(gameID: UUID) async { + let summaries = await sessionMonitor.consumeOnOpen(gameID: gameID) + guard !summaries.isEmpty else { return } + let body = Self.formatSummaryBanner(summaries) + announcements.post(Announcement( + id: "session-summary-\(gameID.uuidString)", + scope: .game(gameID), + severity: .info, + body: body, + dismissal: .transient(after: 6) + )) + } + + nonisolated static func formatSummaryBanner(_ summaries: [SessionMonitor.SessionSummary]) -> String { + guard !summaries.isEmpty else { return "" } + let phrases: [String] = summaries.map { summary in + let name = summary.playerName.isEmpty ? "A player" : summary.playerName + var parts: [String] = [] + if summary.added > 0 { + parts.append("added \(summary.added) \(summary.added == 1 ? "letter" : "letters")") + } + if summary.cleared > 0 { + parts.append("cleared \(summary.cleared) \(summary.cleared == 1 ? "letter" : "letters")") + } + let action = parts.isEmpty ? "made changes" : parts.joined(separator: " and ") + return "\(name) \(action)" + } + return phrases.joined(separator: "; ") + } + nonisolated static func bodyText(for session: Session) -> String { let player = session.playerName.isEmpty ? "A player" : session.playerName let puzzleSuffix = session.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(session.puzzleTitle)'" diff --git a/Crossmate/Sync/SessionMonitor.swift b/Crossmate/Sync/SessionMonitor.swift @@ -116,6 +116,50 @@ actor SessionMonitor { } } + /// One author's worth of unseen activity, surfaced to the puzzle's + /// announcement banner when the user opens a game. Hydrated with the + /// player and puzzle names so the caller can format a body string + /// without a second Core Data round-trip. + struct SessionSummary: Equatable, Sendable { + let gameID: UUID + let authorID: String + let playerName: String + let puzzleTitle: String + let added: Int + let cleared: Int + } + + /// Pulls the running tallies for every author in `gameID` out as + /// summaries, drops their buckets, and withdraws the pending + /// end-of-session notifications. Called from the puzzle-open path — + /// the banner supersedes the would-have-fired local notification, so + /// we don't want it queued up for delivery a few minutes later. + func consumeOnOpen(gameID: UUID) async -> [SessionSummary] { + let keysForGame = buckets.keys.filter { $0.gameID == gameID } + guard !keysForGame.isEmpty else { return [] } + var summaries: [SessionSummary] = [] + for key in keysForGame { + guard let bucket = buckets[key], bucket.added > 0 || bucket.cleared > 0 + else { continue } + let (playerName, puzzleTitle) = await nameLookup(key.gameID, key.authorID) + summaries.append(SessionSummary( + gameID: key.gameID, + authorID: key.authorID, + playerName: playerName, + puzzleTitle: puzzleTitle, + added: bucket.added, + cleared: bucket.cleared + )) + } + for key in keysForGame { + buckets.removeValue(forKey: key) + } + let identifiers = keysForGame.map(Self.endIdentifier(for:)) + notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers) + notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers) + return summaries + } + /// Withdraws the pending end-of-session notification and tally for /// `(gameID, authorID)`. Pass `authorID == nil` to drop every author's /// bucket for the game — used when the user opens the puzzle, a `.win` diff --git a/Crossmate/Views/AnnouncementBanner.swift b/Crossmate/Views/AnnouncementBanner.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// Banner-style surface for an `Announcement`. Renders body text with a +/// severity-tinted background, taps to dismiss when `.manual`, and is +/// intended to live in the puzzle header (and eventually the game list) +/// behind a `.transition(.move(edge: .top))` driven by the parent. +struct AnnouncementBanner: View { + let announcement: Announcement + let onDismiss: (() -> Void)? + + var body: some View { + let tint = backgroundTint(for: announcement.severity) + VStack(spacing: 2) { + if let title = announcement.title, !title.isEmpty { + Text(title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + } + Text(announcement.body) + .font(.subheadline) + .multilineTextAlignment(.center) + .lineLimit(3) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(tint, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .contentShape(Rectangle()) + .onTapGesture { + // `.transient` self-dismisses; `.sticky` requires programmatic + // dismissal. Only `.manual` reacts to a tap. + guard case .manual = announcement.dismissal else { return } + onDismiss?() + } + .accessibilityElement(children: .combine) + } + + private func backgroundTint(for severity: Announcement.Severity) -> Color { + switch severity { + case .info: return Color(.tertiarySystemFill) + case .warning: return Color.orange.opacity(0.18) + case .error: return Color.red.opacity(0.18) + } + } +} diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -237,7 +237,8 @@ struct PuzzleView: View { roster: roster, title: titleParts.title, subtitle: titleParts.subtitle, - showsScoreboard: padLayout == nil + showsScoreboard: padLayout == nil, + gameID: session.mutator.gameID ) GridView( session: session, @@ -1053,7 +1054,14 @@ private struct PuzzleHeader: View { let title: String let subtitle: String? let showsScoreboard: Bool + let gameID: UUID + @Environment(AnnouncementCenter.self) private var announcements @State private var selection: Page = .title + /// Holds off looking at the announcement queue for a moment after + /// open, so the title is the only thing on screen during the + /// puzzle-open beat. Once it flips, banner posts (including the + /// session summary that arrived during the hold) animate in. + @State private var announcementsArmed = false private enum Page: Hashable { case title @@ -1092,6 +1100,42 @@ private struct PuzzleHeader: View { } var body: some View { + let visibleAnnouncement = announcementsArmed + ? announcements.current(forGame: gameID) + : nil + Group { + // Title/scoreboard/credits is the baseline — it renders + // immediately on open and stays put. After the open beat we + // start reacting to announcements: the banner slides down + // over the title and slides back out on dismissal. Both + // branches occupy the same fixed-height frame so the grid + // below doesn't jump. + if let announcement = visibleAnnouncement { + AnnouncementBanner(announcement: announcement) { + announcements.dismiss(id: announcement.id) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + headerPages + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .frame(height: 80) + .padding(.bottom, 14) + .animation(.easeInOut(duration: 0.3), value: visibleAnnouncement) + .task { + // 1-second hold lets the puzzle settle visually before any + // banner animates in. Posts that arrive during the hold are + // applied on arming, so a session summary still shows — it + // just shows after the open beat instead of racing it. + try? await Task.sleep(for: .milliseconds(750)) + announcementsArmed = true + } + } + + private var headerPages: some View { VStack(spacing: 10) { TabView(selection: $selection) { ForEach(pages, id: \.self) { page in @@ -1112,8 +1156,6 @@ private struct PuzzleHeader: View { .animation(.easeInOut(duration: 0.2), value: selection) } } - .frame(height: 80) - .padding(.bottom, 14) } @ViewBuilder diff --git a/Tests/Unit/AnnouncementCenterTests.swift b/Tests/Unit/AnnouncementCenterTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("AnnouncementCenter") +@MainActor +struct AnnouncementCenterTests { + + private func makeAnnouncement( + id: String = "test", + scope: Announcement.Scope = .global, + severity: Announcement.Severity = .info, + body: String = "Body", + dismissal: Announcement.Dismissal = .manual, + blocksInput: Bool = false, + createdAt: Date = Date() + ) -> Announcement { + Announcement( + id: id, + scope: scope, + severity: severity, + body: body, + dismissal: dismissal, + blocksInput: blocksInput, + createdAt: createdAt + ) + } + + @Test("Posting an announcement makes it the current for its scope") + func postShowsAsCurrent() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(scope: .game(gameID), body: "Hi")) + #expect(center.current(forGame: gameID)?.body == "Hi") + } + + @Test("Reposting with the same id replaces the prior announcement in place") + func replaceById() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(id: "k", scope: .game(gameID), body: "first")) + center.post(makeAnnouncement(id: "k", scope: .game(gameID), body: "second")) + #expect(center.current(forGame: gameID)?.body == "second") + } + + @Test("dismiss(id:) removes the announcement") + func dismissRemoves() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(id: "k", scope: .game(gameID))) + center.dismiss(id: "k") + #expect(center.current(forGame: gameID) == nil) + } + + @Test("Game-scoped announcements take priority over global ones for the puzzle header") + func scopePrecedence() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(id: "g", scope: .global, body: "global")) + center.post(makeAnnouncement(id: "p", scope: .game(gameID), body: "game")) + #expect(center.current(forGame: gameID)?.body == "game") + // currentGlobal still surfaces the global one for surfaces that + // have no specific game context. + #expect(center.currentGlobal()?.body == "global") + } + + @Test("Higher severity wins among multiple announcements of the same scope") + func severityWinsWithinScope() { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement(id: "info", scope: .game(gameID), severity: .info, body: "i")) + center.post(makeAnnouncement(id: "error", scope: .game(gameID), severity: .error, body: "e")) + center.post(makeAnnouncement(id: "warning", scope: .game(gameID), severity: .warning, body: "w")) + #expect(center.current(forGame: gameID)?.body == "e") + } + + @Test(".transient announcements auto-dismiss after their delay") + func transientAutoDismisses() async { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement( + id: "k", + scope: .game(gameID), + dismissal: .transient(after: 0.05) + )) + #expect(center.current(forGame: gameID) != nil) + try? await Task.sleep(for: .milliseconds(200)) + #expect(center.current(forGame: gameID) == nil) + } + + @Test("Replacing a .transient with a .sticky cancels the pending auto-dismiss") + func replacementCancelsAutoDismiss() async { + let center = AnnouncementCenter() + let gameID = UUID() + center.post(makeAnnouncement( + id: "k", + scope: .game(gameID), + dismissal: .transient(after: 0.05) + )) + // Replace with a sticky one before the transient delay elapses. + center.post(makeAnnouncement( + id: "k", + scope: .game(gameID), + body: "sticky", + dismissal: .sticky + )) + try? await Task.sleep(for: .milliseconds(200)) + // The transient's scheduled dismissal should not have fired + // against the sticky replacement. + #expect(center.current(forGame: gameID)?.body == "sticky") + } + + @Test("isInputBlocked reflects the current announcement's blocksInput flag") + func inputBlockedFollowsCurrent() { + let center = AnnouncementCenter() + let gameID = UUID() + #expect(!center.isInputBlocked(forGame: gameID)) + center.post(makeAnnouncement( + id: "k", + scope: .game(gameID), + dismissal: .sticky, + blocksInput: true + )) + #expect(center.isInputBlocked(forGame: gameID)) + center.dismiss(id: "k") + #expect(!center.isInputBlocked(forGame: gameID)) + } +} diff --git a/Tests/Unit/Sync/AppServicesAnnouncementTests.swift b/Tests/Unit/Sync/AppServicesAnnouncementTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("AppServices.formatSummaryBanner") +struct AppServicesAnnouncementTests { + + private let gameID = UUID() + + private func summary( + author: String, + playerName: String, + added: Int = 0, + cleared: Int = 0, + puzzleTitle: String = "Tuesday Mini" + ) -> SessionMonitor.SessionSummary { + SessionMonitor.SessionSummary( + gameID: gameID, + authorID: author, + playerName: playerName, + puzzleTitle: puzzleTitle, + added: added, + cleared: cleared + ) + } + + @Test("Single-author summary omits the puzzle suffix (the user is already in the puzzle)") + func singleAuthor() { + let body = AppServices.formatSummaryBanner([ + summary(author: "a", playerName: "Alice", added: 4), + ]) + #expect(body == "Alice added 4 letters") + } + + @Test("Multi-author summary joins author phrases with '; '") + func multipleAuthors() { + let body = AppServices.formatSummaryBanner([ + summary(author: "a", playerName: "Alice", added: 4), + summary(author: "b", playerName: "Bob", added: 1, cleared: 2), + ]) + #expect(body == "Alice added 4 letters; Bob added 1 letter and cleared 2 letters") + } + + @Test("Missing player name falls back to 'A player'") + func fallbacks() { + let body = AppServices.formatSummaryBanner([ + summary(author: "a", playerName: "", added: 3), + ]) + #expect(body == "A player added 3 letters") + } +} diff --git a/Tests/Unit/Sync/SessionMonitorTests.swift b/Tests/Unit/Sync/SessionMonitorTests.swift @@ -217,6 +217,45 @@ struct SessionMonitorTests { )) } + @Test("consumeOnOpen returns hydrated summaries and clears the buckets") + @MainActor func consumeOnOpenReturnsAndClears() async { + let scheduler = RecordingNotificationScheduler() + let monitor = SessionMonitor( + persistence: makeTestPersistence(), + localAuthorIDProvider: { nil }, + notificationCenter: scheduler, + nameLookup: { _, authorID in ("Player \(authorID)", "Tuesday Mini") }, + suppressionGate: { _ in false } + ) + let gameID = UUID() + await monitor.ingest([ + makeDelta(gameID: gameID, authorID: "alice", added: 4), + makeDelta(gameID: gameID, authorID: "bob", added: 1, cleared: 2), + ]) + + let summaries = await monitor.consumeOnOpen(gameID: gameID) + let byAuthor = Dictionary(uniqueKeysWithValues: summaries.map { ($0.authorID, $0) }) + #expect(byAuthor["alice"]?.added == 4) + #expect(byAuthor["alice"]?.playerName == "Player alice") + #expect(byAuthor["alice"]?.puzzleTitle == "Tuesday Mini") + #expect(byAuthor["bob"]?.added == 1) + #expect(byAuthor["bob"]?.cleared == 2) + + // Buckets are drained, scheduled requests withdrawn. + #expect(await monitor.pendingBucketCount == 0) + let withdrawn = Set(scheduler.removedPending).union(scheduler.removedDelivered) + #expect(withdrawn.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "alice"))) + #expect(withdrawn.contains(SessionMonitor.endIdentifier(for: gameID, authorID: "bob"))) + } + + @Test("consumeOnOpen on a game with no pending tallies returns an empty list") + @MainActor func consumeOnOpenEmpty() async { + let scheduler = RecordingNotificationScheduler() + let monitor = makeMonitor(scheduler: scheduler) + let summaries = await monitor.consumeOnOpen(gameID: UUID()) + #expect(summaries.isEmpty) + } + @Test("Notification body pluralises and combines added + cleared") func bodyTextWording() { let alice = SessionMonitor.bodyText(playerName: "Alice", puzzleTitle: "Tuesday Mini", added: 1, cleared: 0)