crossmate

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

commit 99db6f67d273c85c0b335be5305bea5bc9d630ba
parent 649f86acf78e6a84324da02a98411cee6dc85760
Author: Michael Camilleri <[email protected]>
Date:   Mon, 27 Apr 2026 09:10:27 +0900

Gate share button

This commit gates the share button so that it doesn't offer to share a
game unless the user is signed into iCloud. It also tries to move
the share menu in the game list to appear in a popover menu.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/Sync/ShareController.swift | 26++++++++++++++++++++++++--
ACrossmate/Views/CloudSharingPopover.swift | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/GameListView.swift | 33++++++++++++++++++++++++---------
MCrossmate/Views/PuzzleView.swift | 36++++++++++++++++++++++++------------
5 files changed, 157 insertions(+), 23 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; }; 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C8064F04FC6177D987ACA2 /* Puzzle.swift */; }; 54464FDFB8C71B0D3B4B61A2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FEFF257CDDD3EF0E77CBF7 /* SettingsView.swift */; }; + 69B090CECDD8360288CE10DC /* CloudSharingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8ECFCF3E5647F6121369963 /* CloudSharingPopover.swift */; }; 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = B135C285570F91181595B405 /* CellMark.swift */; }; 6BFB5945FCCDEC64C431C2AC /* SyncDiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C90BA83B6DC7F435A7CF24 /* SyncDiagnosticsView.swift */; }; 765B50552B13175F91A25EA1 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAB4BB9E160C3A59C653E7A9 /* GridView.swift */; }; @@ -154,6 +155,7 @@ DB55FC337CF72C650373210A /* PlayerColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerColor.swift; sourceTree = "<group>"; }; DB851649DE78AAAC5A928C52 /* Square.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Square.swift; sourceTree = "<group>"; }; E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; }; + E8ECFCF3E5647F6121369963 /* CloudSharingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSharingPopover.swift; sourceTree = "<group>"; }; E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueList.swift; sourceTree = "<group>"; }; EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; }; EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStoreTests.swift; sourceTree = "<group>"; }; @@ -277,6 +279,7 @@ 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */, C0CAA5E17BD406AFEEF96196 /* CalendarDayCell.swift */, F8E50E7BA98C88B4CAB39DC1 /* CellView.swift */, + E8ECFCF3E5647F6121369963 /* CloudSharingPopover.swift */, E9BD3F7EAFD344D8E10E8C3B /* ClueList.swift */, 38DDAD9D6470A894C3FD6F90 /* GameListView.swift */, 5ABB557BA10CBE9909056882 /* GameShareItem.swift */, @@ -465,6 +468,7 @@ 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, CFCA3C2C3CF6D88AE844D7AD /* CellView.swift in Sources */, CC250D6BA9B41CB722D8A62E /* CloudService.swift in Sources */, + 69B090CECDD8360288CE10DC /* CloudSharingPopover.swift in Sources */, B94919176DEC6EC31637B037 /* ClueList.swift in Sources */, DE9E4FAB098731A650F2D306 /* CrossmateApp.swift in Sources */, C30C0C4E54E4209A22843872 /* CrossmateModel.xcdatamodeld in Sources */, diff --git a/Crossmate/Sync/ShareController.swift b/Crossmate/Sync/ShareController.swift @@ -1,15 +1,21 @@ import CloudKit import CoreData import Foundation +import Observation /// Manages the lifecycle of `CKShare` objects for per-game zones. Responsible /// for creating zone-scoped shares and saving them to CloudKit, refreshing /// existing shares on re-present, and letting participants leave a shared game. @MainActor +@Observable final class ShareController { let container: CKContainer - private let persistence: PersistenceController - private let syncEngine: SyncEngine + @ObservationIgnored private let persistence: PersistenceController + @ObservationIgnored private let syncEngine: SyncEngine + + /// Latest known iCloud account status. `nil` until the first refresh. + /// Drives whether share UI can actually present anything. + private(set) var accountStatus: CKAccountStatus? enum ShareError: Error { case gameNotFound @@ -21,6 +27,22 @@ final class ShareController { self.container = container self.persistence = persistence self.syncEngine = syncEngine + Task { await self.refreshAccountStatus() } + Task { await self.observeAccountChanges() } + } + + func refreshAccountStatus() async { + do { + accountStatus = try await container.accountStatus() + } catch { + accountStatus = .couldNotDetermine + } + } + + private func observeAccountChanges() async { + for await _ in NotificationCenter.default.notifications(named: .CKAccountChanged) { + await refreshAccountStatus() + } } /// Returns the `CKShare` and container for `UICloudSharingController`'s diff --git a/Crossmate/Views/CloudSharingPopover.swift b/Crossmate/Views/CloudSharingPopover.swift @@ -0,0 +1,81 @@ +import CloudKit +import SwiftUI +import UIKit + +/// `UIViewControllerRepresentable` over `UICloudSharingController`, intended to +/// be the content of a SwiftUI `.popover`. Pair with +/// `.presentationCompactAdaptation(.popover)` to keep the popover anchored to +/// its source on iPhone instead of adapting to a bottom sheet. +struct CloudSharingPopover: UIViewControllerRepresentable { + let gameID: UUID + let title: String + let shareController: ShareController + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> UICloudSharingController { + let csc = UICloudSharingController { [shareController, gameID] (_, completion) in + Task { @MainActor in + do { + let (share, container) = try await shareController.prepareShare(for: gameID) + completion(share, container, nil) + } catch { + completion(nil, nil, error) + } + } + } + csc.delegate = context.coordinator + csc.availablePermissions = [.allowReadWrite, .allowPrivate] + return csc + } + + func updateUIViewController(_ uiViewController: UICloudSharingController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator( + title: title, + gameID: gameID, + shareController: shareController, + onDismiss: onDismiss + ) + } + + @MainActor + final class Coordinator: NSObject, UICloudSharingControllerDelegate { + let title: String + let gameID: UUID + let shareController: ShareController + let onDismiss: () -> Void + + init( + title: String, + gameID: UUID, + shareController: ShareController, + onDismiss: @escaping () -> Void + ) { + self.title = title + self.gameID = gameID + self.shareController = shareController + self.onDismiss = onDismiss + } + + func itemTitle(for csc: UICloudSharingController) -> String? { title } + + func cloudSharingController( + _ csc: UICloudSharingController, + failedToSaveShareWithError error: Error + ) { + onDismiss() + } + + func cloudSharingControllerDidSaveShare(_ csc: UICloudSharingController) { + if let share = csc.share { + try? shareController.persistShareName(share.recordID.recordName, for: gameID) + } + onDismiss() + } + + func cloudSharingControllerDidStopSharing(_ csc: UICloudSharingController) { + onDismiss() + } + } +} diff --git a/Crossmate/Views/GameListView.swift b/Crossmate/Views/GameListView.swift @@ -177,6 +177,9 @@ private struct GameRowView: View { var onResign: () -> Void = {} var onDelete: () -> Void = {} + @State private var showSharePopover = false + @State private var showSignInAlert = false + var body: some View { HStack(spacing: 12) { GridThumbnailView( @@ -206,15 +209,13 @@ private struct GameRowView: View { } Spacer() if game.isOwned { - ShareLink( - item: GameShareItem( - gameID: game.id, - title: game.title, - container: shareController.container, - shareController: shareController - ), - preview: SharePreview(game.title) - ) { + Button { + if shareController.accountStatus == .available { + showSharePopover = true + } else { + showSignInAlert = true + } + } label: { Image(systemName: "square.and.arrow.up") .font(.body) .frame(width: 32, height: 32) @@ -223,6 +224,20 @@ private struct GameRowView: View { .buttonStyle(.borderless) .tint(.secondary) .compositingGroup() + .popover(isPresented: $showSharePopover) { + CloudSharingPopover( + gameID: game.id, + title: game.title, + shareController: shareController, + onDismiss: { showSharePopover = false } + ) + .presentationCompactAdaptation(.popover) + } + .alert("Sign in to iCloud", isPresented: $showSignInAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Sharing requires an iCloud account. Sign in via Settings to share games.") + } } Menu { Button { onLeave() } label: { Label("Leave", systemImage: "rectangle.portrait.and.arrow.right") } diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -14,6 +14,8 @@ struct PuzzleView: View { @State private var isConfirmingLeave = false @State private var leaveError: String? @State private var isRevokedBannerDismissed = false + @State private var showSharePopover = false + @State private var showSignInAlert = false private func swatchImage(for color: PlayerColor) -> Image { let tint = UIColor(color.tint) @@ -183,24 +185,34 @@ struct PuzzleView: View { } .disabled(!(session.mutator.isShared && !session.mutator.isOwned) || shareController == nil) - if let shareController { - ShareLink( - item: GameShareItem( - gameID: session.mutator.gameID, - title: session.puzzle.title, - container: shareController.container, - shareController: shareController - ), - preview: SharePreview(session.puzzle.title) - ) { - Text("Share Game") + Button("Share Game") { + if shareController?.accountStatus == .available { + showSharePopover = true + } else { + showSignInAlert = true } - .disabled(!session.mutator.isOwned) } + .disabled(!session.mutator.isOwned || shareController == nil) } } label: { Label("Players", systemImage: "person.2") } + .popover(isPresented: $showSharePopover) { + if let shareController { + CloudSharingPopover( + gameID: session.mutator.gameID, + title: session.puzzle.title, + shareController: shareController, + onDismiss: { showSharePopover = false } + ) + .presentationCompactAdaptation(.popover) + } + } + .alert("Sign in to iCloud", isPresented: $showSignInAlert) { + Button("OK", role: .cancel) {} + } message: { + Text("Sharing requires an iCloud account. Sign in via Settings to share games.") + } } } .task {