crossmate

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

commit cb8e7ee0e14c2f4cfcba8038bbc3ddd1fce7009a
parent 383f9235f07e0ede550d3b9edb1d064477c38d7b
Author: Michael Camilleri <[email protected]>
Date:   Thu,  7 May 2026 02:51:09 +0900

Update text in notification messages

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
ACrossmate/Models/PuzzleNotificationText.swift | 23+++++++++++++++++++++++
MCrossmate/Services/AppServices.swift | 7++++---
MCrossmate/Sync/SyncEngine.swift | 12+++++++++++-
ATests/Unit/PuzzleNotificationTextTests.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 91 insertions(+), 4 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */; }; 3A5483EF2893AE325DF27EE8 /* GameMutator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC132D49361C56DE79C13E /* GameMutator.swift */; }; 3D407AF18566F6BA5261DF55 /* MoveBufferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC1B64755AE15CF45350DBB /* MoveBufferTests.swift */; }; + 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */; }; 453E30B78DFB4B689D70EE2C /* GameStoreUnseenMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */; }; 47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; }; 4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; }; @@ -42,6 +43,7 @@ 78802AFDF6273231781CC0DC /* AppServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */; }; 7C0AAD1DD6086E76A6DA806B /* NameBroadcasterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */; }; 7E54EC2E507C3BFD615FD621 /* MoveLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7422F19AA1F1692A98E3602 /* MoveLog.swift */; }; + 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */; }; 7FFEACFC672925A0968ACC1C /* XD.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9031A1574C21866940F6A2C /* XD.swift */; }; 818B1F2693962832BE14578E /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */; }; 82918A74836E5076CBFA1592 /* SyncEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */; }; @@ -166,8 +168,10 @@ BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameMutatorTests.swift; sourceTree = "<group>"; }; C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayCell.swift; sourceTree = "<group>"; }; C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTToXDConverterTests.swift; sourceTree = "<group>"; }; + C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationTextTests.swift; sourceTree = "<group>"; }; CAB4BB9E160C3A59C653E7A9 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; }; CBDC81CA6A9C80EB31E7F493 /* AppServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppServices.swift; sourceTree = "<group>"; }; + D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleNotificationText.swift; sourceTree = "<group>"; }; D3916C52FE26549625BD18A4 /* GameStoreUnseenMovesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStoreUnseenMovesTests.swift; sourceTree = "<group>"; }; D97CBA409832A24D64DF0F5C /* Crossmate Unit Tests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "Crossmate Unit Tests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridThumbnailView.swift; sourceTree = "<group>"; }; @@ -230,6 +234,7 @@ C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */, 1813630FA05C194AFF43855C /* PlayerRosterTests.swift */, ACD511B95227C7D57F32A2AC /* PresencePublisherTests.swift */, + C90E94A01FEA77A5C9A2BC94 /* PuzzleNotificationTextTests.swift */, 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */, 2899F77BEA1575B835D6EC3D /* SnapshotServiceTests.swift */, ABB371EF2574E95782CB05FD /* Sync */, @@ -251,6 +256,7 @@ 20B331CC55827FEF3420ABCE /* PlayerSession.swift */, 64C8064F04FC6177D987ACA2 /* Puzzle.swift */, 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */, + D2F03A9F357672533E2A8DB0 /* PuzzleNotificationText.swift */, E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */, DB851649DE78AAAC5A928C52 /* Square.swift */, B9031A1574C21866940F6A2C /* XD.swift */, @@ -488,6 +494,7 @@ 014134FB81566B5D41168260 /* PerGameZoneTests.swift in Sources */, 04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */, 090039C84FAE212D5B0EA03F /* PresencePublisherTests.swift in Sources */, + 7FCD3F582B5ADC235E1F88A0 /* PuzzleNotificationTextTests.swift in Sources */, ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */, BE57957589423497338EBD37 /* ShareRoutingTests.swift in Sources */, 6A0B4EF7A6E66D9689BF6790 /* SnapshotServiceTests.swift in Sources */, @@ -545,6 +552,7 @@ D74E9307FC03801137BE2083 /* PresencePublisher.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, + 40256E08EE741F4C414B842B /* PuzzleNotificationText.swift in Sources */, E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */, 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */, CF0CA17ABE211DAE4DD35AFD /* RecordSerializer.swift in Sources */, diff --git a/Crossmate/Models/PuzzleNotificationText.swift b/Crossmate/Models/PuzzleNotificationText.swift @@ -0,0 +1,23 @@ +import Foundation + +enum PuzzleNotificationText { + static func title(_ title: String, publisher: String?, date: Date?) -> String { + let subtitle = subtitle(publisher: publisher, date: date) + guard let subtitle else { return title } + return "\(title) – \(subtitle)" + } + + private static func subtitle(publisher: String?, date: Date?) -> String? { + let formattedDate = date?.formatted(date: .long, time: .omitted) + if let publisher, !publisher.isEmpty, let formattedDate { + return "\(publisher) · \(formattedDate)" + } + if let formattedDate { + return formattedDate + } + if let publisher, !publisher.isEmpty { + return publisher + } + return nil + } +} diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -367,9 +367,9 @@ final class AppServices { } } - static func bodyText(for ping: Ping) -> String { + nonisolated static func bodyText(for ping: Ping) -> String { let player = ping.playerName.isEmpty ? "A player" : ping.playerName - let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle \(ping.puzzleTitle)" + let puzzleSuffix = ping.puzzleTitle.isEmpty ? "the puzzle" : "the puzzle '\(ping.puzzleTitle)'" switch ping.kind { case .session: return "\(player) is solving \(puzzleSuffix)" @@ -381,7 +381,8 @@ final class AppServices { switch ping.scope { case .square: return "\(player) checked a square in \(puzzleSuffix)" case .word: return "\(player) checked a word in \(puzzleSuffix)" - case .puzzle, .none: return "\(player) checked \(puzzleSuffix)" + case .puzzle: return "\(player) checked all of \(puzzleSuffix)" + case .none: return "\(player) checked \(puzzleSuffix)" } case .reveal: switch ping.scope { diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -274,7 +274,8 @@ actor SyncEngine { let req = NSFetchRequest<GameEntity>(entityName: "GameEntity") req.predicate = NSPredicate(format: "id == %@", gameID as CVarArg) req.fetchLimit = 1 - let title = (try? ctx.fetch(req).first?.title) ?? "" + let entity = try? ctx.fetch(req).first + let title = Self.notificationTitle(for: entity) return (info, title) } guard let zoneAndTitle else { return } @@ -299,6 +300,15 @@ actor SyncEngine { engine.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)]) } + private nonisolated static func notificationTitle(for entity: GameEntity?) -> String { + guard let entity else { return "" } + return PuzzleNotificationText.title( + entity.title ?? "", + publisher: entity.cachedPublisher, + date: entity.cachedPuzzleDate + ) + } + /// Registers a Player record as a pending send. Used by `NameBroadcaster` /// when the local user renames; one record per (game, authorID), so /// participants only ever write their own slot. diff --git a/Tests/Unit/PuzzleNotificationTextTests.swift b/Tests/Unit/PuzzleNotificationTextTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing + +@testable import Crossmate + +@Suite("Puzzle notification text") +struct PuzzleNotificationTextTests { + @Test("Puzzle title includes subtitle separated by an en dash") + func titleIncludesSubtitle() { + let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2001, month: 1, day: 1)) + + let title = PuzzleNotificationText.title("Saturday Puzzle", publisher: nil, date: date) + + #expect(title.contains("Saturday Puzzle – ")) + #expect(title.contains("2001")) + } + + @Test("Notification body quotes puzzle title") + func bodyQuotesPuzzleTitle() { + let ping = Ping( + gameID: UUID(), + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Saturday Puzzle – 1 January 2001", + kind: .join, + scope: nil + ) + + #expect(AppServices.bodyText(for: ping) == "Alice joined the puzzle 'Saturday Puzzle – 1 January 2001'") + } + + @Test("Puzzle check says all of the puzzle") + func puzzleCheckSaysAllOfPuzzle() { + let ping = Ping( + gameID: UUID(), + authorID: "alice", + playerName: "Alice", + puzzleTitle: "Saturday Puzzle – 1 January 2001", + kind: .check, + scope: .puzzle + ) + + #expect(AppServices.bodyText(for: ping) == "Alice checked all of the puzzle 'Saturday Puzzle – 1 January 2001'") + } +}