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:
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)