commit 4fcc40cc30b3e6f30eef6db55fb24752c2fe5a6c
parent a516df0db69f0b9c01d5107c25f4be6d8e073405
Author: Michael Camilleri <[email protected]>
Date: Mon, 4 May 2026 08:13:44 +0900
Support author tinting on share
When a puzzle is shared, this commit updates the currently entered
squares with an author tint.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
5 files changed, 55 insertions(+), 32 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -307,42 +307,15 @@ private struct PuzzleDisplayView: View {
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.task(id: sharedID) {
- guard sharedID != nil else { return }
+ guard let session, sharedID != nil, preferences.isICloudSyncEnabled else { return }
+ await activateSharing(for: session)
await pollOpenSharedPuzzle()
}
.task(id: gameID) {
NotificationState.setActivePuzzleID(gameID)
do {
let (game, mutator) = try store.loadGame(id: gameID)
- let newSession = PlayerSession(game: game, mutator: mutator)
- if mutator.isShared && preferences.isICloudSyncEnabled {
- Task { await AppDelegate.requestNotificationAuthorizationIfNeeded() }
- roster = services.makePlayerRoster(for: gameID, preferences: preferences)
- // 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 observer.
- // Idempotent if the name has already been broadcast.
- await services.nameBroadcaster?.broadcastName()
- if let authorID = services.identity.currentID {
- let presence = services.presencePublisher
- await presence.begin(
- gameID: gameID,
- authorID: authorID,
- currentName: preferences.name
- )
- newSession.onSelectionChanged = { selection in
- Task { await presence.publish(selection) }
- }
- let initial = PlayerSelection(
- row: newSession.selectedRow,
- col: newSession.selectedCol,
- direction: newSession.direction
- )
- await presence.publish(initial)
- }
- }
- session = newSession
+ session = PlayerSession(game: game, mutator: mutator)
} catch {
loadError = String(describing: error)
}
@@ -359,6 +332,37 @@ private struct PuzzleDisplayView: View {
}
}
+ /// Initialises shared-game state (roster, presence, name broadcast) for
+ /// the open session. Called when the puzzle first appears as shared, and
+ /// 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)
+ }
+ // 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
+ // observer. Idempotent if the name has already been broadcast.
+ await services.nameBroadcaster?.broadcastName()
+ guard let authorID = services.identity.currentID else { return }
+ let presence = services.presencePublisher
+ await presence.begin(
+ gameID: gameID,
+ authorID: authorID,
+ currentName: preferences.name
+ )
+ session.onSelectionChanged = { selection in
+ Task { await presence.publish(selection) }
+ }
+ let initial = PlayerSelection(
+ row: session.selectedRow,
+ col: session.selectedCol,
+ direction: session.direction
+ )
+ await presence.publish(initial)
+ }
+
private func pollOpenSharedPuzzle() async {
await services.syncOpenSharedPuzzle()
while !Task.isCancelled {
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -21,8 +21,10 @@ final class GameMutator {
/// `true` when the current user owns the CloudKit zone for this game.
let isOwned: Bool
/// `true` when the game is shared — either the owner has an active share
- /// or the current user joined via one.
- let isShared: Bool
+ /// or the current user joined via one. Mutable so the store can flip it
+ /// when a share is created mid-session, which lets `PuzzleDisplayView`
+ /// react and build a roster without requiring the user to re-open.
+ var isShared: Bool
/// Set to `true` when the owner has revoked the current user's access to
/// a shared game. `emitMove` becomes a no-op and `PuzzleView` shows a
diff --git a/Crossmate/Persistence/GameStore.swift b/Crossmate/Persistence/GameStore.swift
@@ -832,6 +832,14 @@ final class GameStore {
currentMutator?.isAccessRevoked = true
}
+ /// Flips the active game's mutator to shared after `ShareController`
+ /// saves a `CKShare`, so an open `PuzzleView` reacts (builds the roster,
+ /// starts publishing presence) without requiring the user to re-open.
+ func markShared(gameID: UUID) {
+ guard currentEntity?.id == gameID else { return }
+ currentMutator?.isShared = true
+ }
+
private func makeMutator(game: Game, entity: GameEntity) -> GameMutator {
guard let gameID = entity.id else {
fatalError("GameEntity missing id — data model invariant violated")
diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift
@@ -49,6 +49,9 @@ final class AppServices {
syncEngine: syncEngine,
syncMonitor: self.syncMonitor
)
+ self.shareController.onShareSaved = { [weak store] gameID in
+ store?.markShared(gameID: gameID)
+ }
let moveBuffer = MoveBuffer(
debounceInterval: .milliseconds(1500),
persistence: persistence,
diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift
@@ -14,6 +14,11 @@ final class ShareController {
private let syncEngine: SyncEngine
private let syncMonitor: SyncMonitor?
+ /// Fired after `persistShareName` has saved the local entity's
+ /// `ckShareRecordName`, so dependent state (e.g. the open game's mutator
+ /// `isShared` flag) can flip without waiting for the user to re-open.
+ var onShareSaved: (@MainActor (UUID) -> Void)?
+
enum ShareError: Error {
case gameNotFound
case invalidShareRecord
@@ -178,6 +183,7 @@ final class ShareController {
guard entity.ckShareRecordName != recordName else { return }
entity.ckShareRecordName = recordName
try ctx.save()
+ onShareSaved?(gameID)
}
/// Removes the current user's participation from a shared game and deletes