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