GameShareItem.swift (16588B)
1 import Foundation 2 import SwiftUI 3 4 struct GameShareSheet: View { 5 let gameID: UUID 6 let title: String 7 let shareController: ShareController 8 9 @Environment(\.inviteFriend) private var inviteFriend 10 @Environment(\.dismiss) private var dismiss 11 @FetchRequest( 12 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 13 predicate: NSPredicate(format: "isBlocked == NO"), 14 animation: .default 15 ) 16 private var friends: FetchedResults<FriendEntity> 17 18 @State private var shareURL: URL? 19 @State private var errorMessage: String? 20 @State private var isLoadingExistingLink = true 21 @State private var isCreating = false 22 @State private var didLoadExistingLink = false 23 @State private var didCopy = false 24 @State private var invitingAuthorID: String? 25 @State private var invitedAuthorIDs: Set<String> 26 @State private var isInviteLimitReached = false 27 28 init(gameID: UUID, title: String, shareController: ShareController) { 29 self.gameID = gameID 30 self.title = title 31 self.shareController = shareController 32 // Seed from the in-memory session set so friends invited a moment ago 33 // already wear their checkmark on the first frame; the .task below 34 // backfills anyone invited in a prior session or on another device. 35 _invitedAuthorIDs = State( 36 initialValue: shareController.invitedAuthorIDsKnownThisSession(for: gameID) 37 ) 38 } 39 40 private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> { 41 Array(friends.prefix(4)) 42 } 43 44 /// A live public link is the definitive signal the owner took the link 45 /// route, so it wins over any participants the share carries — those are 46 /// public joiners, not directly invited friends. CloudKit forbids mixing 47 /// public access with directly added participants on one share (adding a 48 /// participant to a `.readWrite` share throws), so the two routes are 49 /// presented as mutually exclusive: choosing one removes the other. 50 private var isLinkMode: Bool { shareURL != nil } 51 52 /// The direct-invite route is active once a friend has been invited and no 53 /// public link exists. Gated on the absence of a link so a public joiner 54 /// accepting (which also lands a participant) never flips the sheet into 55 /// this mode while the link is still on offer. 56 private var isDirectInviteMode: Bool { shareURL == nil && !invitedAuthorIDs.isEmpty } 57 58 /// Width of the trailing scroll fade. The scroll content gets a matching 59 /// trailing margin so the last item can scroll clear of the fade instead of 60 /// stopping flush beneath it. 61 private static let trailingFadeWidth: CGFloat = 72 62 63 var body: some View { 64 NavigationStack { 65 List { 66 VStack(spacing: 18) { 67 Image(systemName: "flag.pattern.checkered.2.crossed") 68 .font(.system(size: 58, weight: .semibold)) 69 .symbolRenderingMode(.hierarchical) 70 .foregroundStyle(Color.accentColor) 71 .frame(width: 88, height: 88) 72 .accessibilityHidden(true) 73 74 VStack(spacing: 12) { 75 Text("Share via iCloud") 76 .font(.title3.weight(.semibold)) 77 .multilineTextAlignment(.center) 78 .fixedSize(horizontal: false, vertical: true) 79 80 Text(headerSubtitle) 81 .font(.body) 82 .multilineTextAlignment(.center) 83 .fixedSize(horizontal: false, vertical: true) 84 85 Text("Crossmate syncs puzzles using iCloud. Your player name is shared with other players. Players can work on the same puzzle simultaneously or at different times.") 86 .font(.footnote) 87 .foregroundStyle(.secondary) 88 .multilineTextAlignment(.center) 89 .fixedSize(horizontal: false, vertical: true) 90 } 91 } 92 .frame(maxWidth: .infinity) 93 .padding(.horizontal, 8) 94 .padding(.vertical, 4) 95 .listRowInsets(EdgeInsets()) 96 .listRowBackground(Color.clear) 97 98 Section { 99 if isDirectInviteMode { 100 Label("Link Sharing Unavailable", systemImage: "link.badge.plus") 101 .foregroundStyle(.secondary) 102 Text("You've invited a friend directly. Everyone joins this puzzle the same way, so a share link isn't available.") 103 .font(.footnote) 104 .foregroundStyle(.secondary) 105 } else if !isInviteLimitReached { 106 if let shareURL { 107 Button { 108 UIPasteboard.general.string = shareURL.absoluteString 109 didCopy = true 110 } label: { 111 Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc") 112 } 113 114 ShareLink(item: shareURL) { 115 Label("Send Link", systemImage: "square.and.arrow.up") 116 } 117 } else if isLoadingExistingLink { 118 HStack { 119 Label("Checking Link", systemImage: "link") 120 Spacer() 121 ProgressView() 122 } 123 } else { 124 Button { 125 Task { await createLink() } 126 } label: { 127 HStack { 128 Label("Create Link", systemImage: "link") 129 if isCreating { 130 Spacer() 131 ProgressView() 132 } 133 } 134 } 135 .disabled(isCreating || isLoadingExistingLink) 136 } 137 } else { 138 Label("Link Sharing Disabled", systemImage: "link.badge.plus") 139 .foregroundStyle(.secondary) 140 Text("This puzzle already has its crossmates.") 141 .font(.footnote) 142 .foregroundStyle(.secondary) 143 } 144 } 145 146 Section { 147 if isLinkMode { 148 VStack(spacing: 6) { 149 Label("Direct Invites Unavailable", systemImage: "person.crop.circle.badge.xmark") 150 .foregroundStyle(.secondary) 151 Text("You've created a share link. Anyone with the link can join, so direct invites aren't available for this puzzle.") 152 .font(.footnote) 153 .foregroundStyle(.secondary) 154 .multilineTextAlignment(.center) 155 } 156 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 157 .padding(.vertical, 4) 158 } else if visibleFriends.isEmpty { 159 Text("No Prior Crossmates") 160 .font(.body.weight(.medium)) 161 .foregroundStyle(.secondary) 162 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 163 } else { 164 ScrollView(.horizontal, showsIndicators: false) { 165 HStack(spacing: 12) { 166 ForEach(visibleFriends, id: \.authorID) { friend in 167 friendInviteButton(for: friend) 168 } 169 170 NavigationLink { 171 FriendPickerView( 172 gameID: gameID, 173 shareController: shareController, 174 isInviteLimitReached: isInviteLimitReached 175 ) 176 } label: { 177 VStack(spacing: 6) { 178 Image(systemName: "ellipsis") 179 .font(.system(size: 40, weight: .regular)) 180 .frame(width: 40, height: 40) 181 Text("All") 182 .font(.callout.weight(.medium)) 183 .lineLimit(1) 184 } 185 .frame(width: 96, height: 88) 186 } 187 .buttonStyle(.plain) 188 .disabled(isInviteLimitReached || isLoadingExistingLink) 189 } 190 .frame(maxWidth: .infinity) 191 .padding(.vertical, 2) 192 } 193 // Fade a fixed strip at the trailing edge so the row 194 // always reads as scrollable, even when a whole number 195 // of friends lines up flush with the edge. 196 .mask( 197 HStack(spacing: 0) { 198 Rectangle() 199 LinearGradient( 200 colors: [.black, .clear], 201 startPoint: .leading, 202 endPoint: .trailing 203 ) 204 .frame(width: Self.trailingFadeWidth) 205 } 206 ) 207 // Be proportional to the trailing fade width so the last item can 208 // scroll clear of the fade instead of stopping flush 209 // beneath it. 210 .contentMargins(.trailing, (Self.trailingFadeWidth / 2), for: .scrollContent) 211 .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) 212 } 213 } 214 215 if let errorMessage { 216 Section("Error") { 217 Text(errorMessage) 218 .font(.caption.monospaced()) 219 .foregroundStyle(.red) 220 .textSelection(.enabled) 221 Button { 222 UIPasteboard.general.string = errorMessage 223 } label: { 224 Label("Copy Error", systemImage: "doc.on.doc") 225 } 226 } 227 } 228 } 229 .navigationTitle("Share Puzzle") 230 .navigationBarTitleDisplayMode(.inline) 231 .toolbar { 232 ToolbarItem(placement: .cancellationAction) { 233 Button { 234 dismiss() 235 } label: { 236 Image(systemName: "xmark") 237 } 238 .accessibilityLabel("Cancel") 239 } 240 } 241 .task { 242 // All three loads hit CloudKit; run them together so a full 243 // game shows its disabled invite buttons — and already-invited 244 // friends show their checkmark — as fast as the link check. 245 async let seatTaken = shareController.isAtInviteCapacity(for: gameID) 246 async let alreadyInvited = shareController.invitedAuthorIDs(for: gameID) 247 await loadExistingLink() 248 if await seatTaken { 249 isInviteLimitReached = true 250 } 251 let invited = await alreadyInvited 252 if !invited.isEmpty { 253 withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) } 254 } 255 } 256 } 257 } 258 259 private var headerSubtitle: String { 260 if isInviteLimitReached { 261 "This puzzle already has its crossmateszkh3U*TtaCqxmYB/9XWc. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players." 262 } else { 263 "Anyone with the link can join your puzzle. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players." 264 } 265 } 266 267 @ViewBuilder 268 private func friendInviteButton(for friend: FriendEntity) -> some View { 269 let authorID = friend.authorID ?? "" 270 let wasInvited = invitedAuthorIDs.contains(authorID) 271 272 Button { 273 Task { await invite(authorID) } 274 } label: { 275 VStack(spacing: 8) { 276 FriendAvatarView( 277 authorID: authorID, 278 size: 40, 279 invitePhase: invitePhase(authorID: authorID, invited: wasInvited) 280 ) 281 Text(friend.resolvedDisplayName) 282 .font(.callout.weight(.medium)) 283 .lineLimit(1) 284 .minimumScaleFactor(0.8) 285 } 286 .frame(width: 108, height: 88) 287 } 288 .buttonStyle(.plain) 289 .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || isLoadingExistingLink || (isInviteLimitReached && !wasInvited)) 290 } 291 292 /// Maps the row's invite state to the avatar's animation phase. Sending 293 /// is checked first so the glyph keeps wiggling right up until the work 294 /// finishes, then resolves straight into the spin-to-checkmark. 295 private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { 296 if invitingAuthorID == authorID { return .sending } 297 if invited { return .sent } 298 return nil 299 } 300 301 private func invite(_ authorID: String) async { 302 guard !authorID.isEmpty, let inviteFriend else { return } 303 withAnimation(.snappy) { invitingAuthorID = authorID } 304 errorMessage = nil 305 defer { withAnimation(.snappy) { invitingAuthorID = nil } } 306 307 do { 308 try await inviteFriend(gameID, authorID) 309 withAnimation(.snappy) { 310 _ = invitedAuthorIDs.insert(authorID) 311 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 312 } 313 } catch { 314 if case ShareController.ShareError.collaborationLimitReached = error { 315 withAnimation(.snappy) { isInviteLimitReached = true } 316 } 317 errorMessage = describe(error) 318 } 319 } 320 321 private func loadExistingLink() async { 322 guard !didLoadExistingLink else { return } 323 didLoadExistingLink = true 324 isLoadingExistingLink = true 325 errorMessage = nil 326 defer { isLoadingExistingLink = false } 327 328 do { 329 let shape = shareController.gridSilhouette(for: gameID) 330 shareURL = (try await shareController.existingShareLink(for: gameID)) 331 .map { ShareLinkShortener.shortURL(for: $0, title: title, shape: shape) } 332 } catch { 333 errorMessage = describe(error) 334 } 335 } 336 337 private func createLink() async { 338 guard !isCreating, !isLoadingExistingLink else { return } 339 isCreating = true 340 didCopy = false 341 errorMessage = nil 342 defer { isCreating = false } 343 344 do { 345 shareURL = ShareLinkShortener.shortURL( 346 for: try await shareController.createShareLink(for: gameID), 347 title: title, 348 shape: shareController.gridSilhouette(for: gameID) 349 ) 350 } catch { 351 errorMessage = describe(error) 352 } 353 } 354 355 private func describe(_ error: Error) -> String { 356 let nsError = error as NSError 357 let userInfo = nsError.userInfo 358 .map { "\($0.key)=\($0.value)" } 359 .joined(separator: " | ") 360 return "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)" 361 } 362 }