crossmate

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

commit 8b6d81274d17a76339e39f874eac2c0c4741e3b1
parent 93ebd202cf3df9b55009848c09105092944d063d
Author: Michael Camilleri <[email protected]>
Date:   Fri,  8 May 2026 23:33:07 +0900

Make roster always present

Diffstat:
MCrossmate/CrossmateApp.swift | 30++++++++++++++++--------------
MCrossmate/Models/PlayerRoster.swift | 3+++
MCrossmate/Views/PuzzleView.swift | 27++++++++++++++++-----------
MCrossmate/Views/SuccessPanel.swift | 14+++++++++++---
4 files changed, 46 insertions(+), 28 deletions(-)

diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -321,7 +321,7 @@ private struct PuzzleDisplayView: View { var body: some View { Group { - if let session { + if let session, let roster { PuzzleView( session: session, shareController: shareController, @@ -345,7 +345,6 @@ private struct PuzzleDisplayView: View { await pollOpenSharedPuzzle() } .task(id: gameID) { - let hadRoster = roster != nil session = nil roster = nil loadError = nil @@ -353,22 +352,19 @@ private struct PuzzleDisplayView: View { do { let (game, mutator) = try store.loadGame(id: gameID) let newSession = PlayerSession(game: game, mutator: mutator) + let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) + roster = newRoster + await newRoster.refresh() session = newSession if mutator.isShared && preferences.isICloudSyncEnabled { services.syncMonitor.note( - "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: activating shared roster" + "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded shared roster" ) await activateSharing(for: newSession) } else { - if hadRoster { - services.syncMonitor.note( - "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: cleared stale roster for local game" - ) - } else { - services.syncMonitor.note( - "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: local game loaded without roster" - ) - } + services.syncMonitor.note( + "PuzzleDisplay[\(gameID.uuidString.prefix(8))]: loaded local roster" + ) await services.presencePublisher.clear() } } catch { @@ -413,9 +409,15 @@ private struct PuzzleDisplayView: View { /// again if a previously-solo game becomes shared mid-session. private func activateSharing(for session: PlayerSession) async { Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() } - if roster == nil { - roster = services.makePlayerRoster(for: gameID, preferences: preferences) + let activeRoster: PlayerRoster + if let roster { + activeRoster = roster + } else { + let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences) + roster = newRoster + activeRoster = newRoster } + await activeRoster.refresh() // Fan out the local user's name to every shared/joined game's zone // before any presence write — otherwise the partner sees "Player" // until we happen to rename ourselves and trigger NameBroadcaster's diff --git a/Crossmate/Models/PlayerRoster.swift b/Crossmate/Models/PlayerRoster.swift @@ -46,6 +46,7 @@ final class PlayerRoster { private let selectionFreshnessWindow: TimeInterval = 60 private(set) var entries: [Entry] = [] + private(set) var localAuthorID: String? private let gameID: UUID private let colorStore: GamePlayerColorStore @@ -131,10 +132,12 @@ final class PlayerRoster { // self vs. remote, so the only safe answer is an empty roster. The // next refresh (after AuthorIdentity populates) will do the real work. guard let localAuthorID = authorIdentity.currentID else { + self.localAuthorID = nil entries = [] remoteSelections = [:] return } + self.localAuthorID = localAuthorID // Pull Core Data fields off a background context. let ctx = persistence.container.newBackgroundContext() diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -3,7 +3,7 @@ import SwiftUI struct PuzzleView: View { @Bindable var session: PlayerSession var shareController: ShareController? = nil - var roster: PlayerRoster? = nil + let roster: PlayerRoster var onComplete: (() -> Void)? = nil var onResign: (() throws -> Void)? = nil var onDelete: (() throws -> Void)? = nil @@ -432,7 +432,7 @@ struct PuzzleView: View { private struct PuzzleScoreboard: View { @Bindable var session: PlayerSession - let roster: PlayerRoster? + let roster: PlayerRoster @Environment(PlayerPreferences.self) private var preferences private struct Score: Identifiable { @@ -523,11 +523,11 @@ private struct PuzzleScoreboard: View { guard !session.puzzle.cells[r][c].isBlock else { continue } let square = session.game.squares[r][c] guard !square.entry.isEmpty, !square.mark.isRevealed else { continue } - counts[square.letterAuthorID, default: 0] += 1 + counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 } } - let entries = roster?.entries ?? [] + let entries = roster.entries let usesLocalFallback = entries.isEmpty let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) let rosterAuthorIDs = Set(entries.map(\.authorID)) @@ -576,6 +576,14 @@ private struct PuzzleScoreboard: View { } } + private func normalizedAuthorID(_ authorID: String?) -> String? { + guard let authorID, + let localAuthorID = roster.localAuthorID, + authorID == localAuthorID + else { return authorID } + return nil + } + var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Players") @@ -616,7 +624,7 @@ private struct PuzzleScoreboard: View { private struct PuzzleToolbarModifier: ViewModifier { let session: PlayerSession - let roster: PlayerRoster? + let roster: PlayerRoster let shareController: ShareController? let isSolved: Bool let canResign: Bool @@ -713,7 +721,7 @@ private struct PuzzleToolbarModifier: ViewModifier { @ViewBuilder private var playerRosterSection: some View { Section { - if let roster, !roster.entries.isEmpty { + if !roster.entries.isEmpty { ForEach(roster.entries) { entry in Button {} label: { Label { @@ -743,9 +751,7 @@ private struct PuzzleToolbarModifier: ViewModifier { ForEach(PlayerColor.palette) { color in Button { preferences.color = color - if let roster { - Task { await roster.reassignOnLocalColorChange(newColor: color) } - } + Task { await roster.reassignOnLocalColorChange(newColor: color) } } label: { Label { Text(color.id == preferences.colorID ? "\(color.name) ✓" : color.name) @@ -801,14 +807,13 @@ private struct PuzzleToolbarModifier: ViewModifier { private struct PuzzleLifecycleModifier: ViewModifier { let session: PlayerSession - let roster: PlayerRoster? + let roster: PlayerRoster @Binding var hasSolved: Bool let onCompletionStateChanged: (Game.CompletionState) -> Void func body(content: Content) -> some View { content .task { - guard let roster else { return } await roster.refresh() } .onAppear { diff --git a/Crossmate/Views/SuccessPanel.swift b/Crossmate/Views/SuccessPanel.swift @@ -2,7 +2,7 @@ import SwiftUI struct SuccessPanel: View { let session: PlayerSession - let roster: PlayerRoster? + let roster: PlayerRoster @Environment(PlayerPreferences.self) private var preferences private struct Contribution: Identifiable { @@ -50,11 +50,11 @@ struct SuccessPanel: View { let entry = square.entry guard !entry.isEmpty else { continue } if cell.solution != nil, !cell.accepts(entry) { continue } - counts[square.letterAuthorID, default: 0] += 1 + counts[normalizedAuthorID(square.letterAuthorID), default: 0] += 1 } } - let entries = roster?.entries ?? [] + let entries = roster.entries let entryByAuthorID = Dictionary(uniqueKeysWithValues: entries.map { ($0.authorID, $0) }) let hasRemotePlayers = entries.contains { !$0.isLocal } let usesLocalFallback = entries.isEmpty @@ -103,6 +103,14 @@ struct SuccessPanel: View { } } + private func normalizedAuthorID(_ authorID: String?) -> String? { + guard let authorID, + let localAuthorID = roster.localAuthorID, + authorID == localAuthorID + else { return authorID } + return nil + } + var body: some View { HStack(alignment: .center, spacing: 16) { VStack(alignment: .center, spacing: 8) {