GameShareItem.swift (12129B)
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 private var visibleFriends: Array<FetchedResults<FriendEntity>.Element> { 29 Array(friends.prefix(4)) 30 } 31 32 var body: some View { 33 NavigationStack { 34 List { 35 VStack(spacing: 18) { 36 Image(systemName: "flag.pattern.checkered.2.crossed") 37 .font(.system(size: 58, weight: .semibold)) 38 .symbolRenderingMode(.hierarchical) 39 .foregroundStyle(Color.accentColor) 40 .frame(width: 88, height: 88) 41 .accessibilityHidden(true) 42 43 VStack(spacing: 12) { 44 Text("Share via iCloud") 45 .font(.title3.weight(.semibold)) 46 .multilineTextAlignment(.center) 47 .fixedSize(horizontal: false, vertical: true) 48 49 Text(headerSubtitle) 50 .font(.body) 51 .multilineTextAlignment(.center) 52 .fixedSize(horizontal: false, vertical: true) 53 54 Text("Crossmate syncs games using iCloud. Your player name is shared with other players. Players can work on the same puzzle simultaneously or at different times.") 55 .font(.footnote) 56 .foregroundStyle(.secondary) 57 .multilineTextAlignment(.center) 58 .fixedSize(horizontal: false, vertical: true) 59 } 60 } 61 .frame(maxWidth: .infinity) 62 .padding(.horizontal, 8) 63 .padding(.vertical, 4) 64 .listRowInsets(EdgeInsets()) 65 .listRowBackground(Color.clear) 66 67 Section { 68 if !isInviteLimitReached { 69 if let shareURL { 70 Button { 71 UIPasteboard.general.string = shareURL.absoluteString 72 didCopy = true 73 } label: { 74 Label(didCopy ? "Copied" : "Copy Link", systemImage: didCopy ? "checkmark" : "doc.on.doc") 75 } 76 77 ShareLink(item: shareURL) { 78 Label("Send Link", systemImage: "square.and.arrow.up") 79 } 80 } else if isLoadingExistingLink { 81 HStack { 82 Label("Checking Link", systemImage: "link") 83 Spacer() 84 ProgressView() 85 } 86 } else { 87 Button { 88 Task { await createLink() } 89 } label: { 90 HStack { 91 Label("Create Link", systemImage: "link") 92 if isCreating { 93 Spacer() 94 ProgressView() 95 } 96 } 97 } 98 .disabled(isCreating || isLoadingExistingLink) 99 } 100 } else { 101 Label("Link Sharing Disabled", systemImage: "link.badge.plus") 102 .foregroundStyle(.secondary) 103 Text("This game already has its crossmate.") 104 .font(.footnote) 105 .foregroundStyle(.secondary) 106 } 107 } 108 109 Section { 110 if visibleFriends.isEmpty { 111 Text("No Prior Crossmates") 112 .font(.body.weight(.medium)) 113 .foregroundStyle(.secondary) 114 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 115 } else { 116 ScrollView(.horizontal, showsIndicators: false) { 117 HStack(spacing: 12) { 118 ForEach(visibleFriends, id: \.authorID) { friend in 119 friendInviteButton(for: friend) 120 } 121 122 NavigationLink { 123 FriendPickerView( 124 gameID: gameID, 125 isInviteLimitReached: isInviteLimitReached 126 ) 127 } label: { 128 VStack(spacing: 6) { 129 Image(systemName: "ellipsis") 130 .font(.system(size: 40, weight: .regular)) 131 .frame(width: 40, height: 40) 132 Text("All") 133 .font(.callout.weight(.medium)) 134 .lineLimit(1) 135 } 136 .frame(width: 96, height: 88) 137 } 138 .buttonStyle(.plain) 139 .disabled(isInviteLimitReached) 140 } 141 .frame(maxWidth: .infinity) 142 .padding(.vertical, 2) 143 } 144 .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) 145 } 146 } 147 148 if let errorMessage { 149 Section("Error") { 150 Text(errorMessage) 151 .font(.caption.monospaced()) 152 .foregroundStyle(.red) 153 .textSelection(.enabled) 154 Button { 155 UIPasteboard.general.string = errorMessage 156 } label: { 157 Label("Copy Error", systemImage: "doc.on.doc") 158 } 159 } 160 } 161 } 162 .navigationTitle("Share Game") 163 .navigationBarTitleDisplayMode(.inline) 164 .toolbar { 165 ToolbarItem(placement: .cancellationAction) { 166 Button { 167 dismiss() 168 } label: { 169 Image(systemName: "xmark") 170 } 171 .accessibilityLabel("Cancel") 172 } 173 } 174 .task { 175 // Both loads hit CloudKit; run them together so a full game 176 // shows its disabled invite buttons as fast as the link check. 177 async let seatTaken = shareController.isAtInviteCapacity(for: gameID) 178 await loadExistingLink() 179 if await seatTaken { 180 isInviteLimitReached = true 181 } 182 } 183 } 184 } 185 186 private var headerSubtitle: String { 187 if isInviteLimitReached { 188 "This game already has its crossmate. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players." 189 } else { 190 "Anyone with the link can join your game. Puzzles are limited to \(ShareController.maximumPeoplePerPuzzle) players." 191 } 192 } 193 194 @ViewBuilder 195 private func friendInviteButton(for friend: FriendEntity) -> some View { 196 let authorID = friend.authorID ?? "" 197 let wasInvited = invitedAuthorIDs.contains(authorID) 198 199 Button { 200 Task { await invite(authorID) } 201 } label: { 202 VStack(spacing: 8) { 203 FriendAvatarView( 204 authorID: authorID, 205 size: 40, 206 invitePhase: invitePhase(authorID: authorID, invited: wasInvited) 207 ) 208 Text(friend.resolvedDisplayName) 209 .font(.callout.weight(.medium)) 210 .lineLimit(1) 211 .minimumScaleFactor(0.8) 212 } 213 .frame(width: 108, height: 88) 214 } 215 .disabled(authorID.isEmpty || invitingAuthorID != nil || wasInvited || (isInviteLimitReached && !wasInvited)) 216 } 217 218 /// Maps the row's invite state to the avatar's animation phase. Sending 219 /// is checked first so the glyph keeps wiggling right up until the work 220 /// finishes, then resolves straight into the spin-to-checkmark. 221 private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { 222 if invitingAuthorID == authorID { return .sending } 223 if invited { return .sent } 224 return nil 225 } 226 227 private func invite(_ authorID: String) async { 228 guard !authorID.isEmpty, let inviteFriend else { return } 229 withAnimation(.snappy) { invitingAuthorID = authorID } 230 errorMessage = nil 231 defer { withAnimation(.snappy) { invitingAuthorID = nil } } 232 233 do { 234 try await inviteFriend(gameID, authorID) 235 withAnimation(.snappy) { 236 _ = invitedAuthorIDs.insert(authorID) 237 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 238 } 239 } catch { 240 if case ShareController.ShareError.collaborationLimitReached = error { 241 withAnimation(.snappy) { isInviteLimitReached = true } 242 } 243 errorMessage = describe(error) 244 } 245 } 246 247 private func loadExistingLink() async { 248 guard !didLoadExistingLink else { return } 249 didLoadExistingLink = true 250 isLoadingExistingLink = true 251 errorMessage = nil 252 defer { isLoadingExistingLink = false } 253 254 do { 255 let shape = shareController.gridSilhouette(for: gameID) 256 shareURL = (try await shareController.existingShareLink(for: gameID)) 257 .map { ShareLinkShortener.shortURL(for: $0, title: title, shape: shape) } 258 } catch { 259 errorMessage = describe(error) 260 } 261 } 262 263 private func createLink() async { 264 guard !isCreating, !isLoadingExistingLink else { return } 265 isCreating = true 266 didCopy = false 267 errorMessage = nil 268 defer { isCreating = false } 269 270 do { 271 shareURL = ShareLinkShortener.shortURL( 272 for: try await shareController.createShareLink(for: gameID), 273 title: title, 274 shape: shareController.gridSilhouette(for: gameID) 275 ) 276 } catch { 277 errorMessage = describe(error) 278 } 279 } 280 281 private func describe(_ error: Error) -> String { 282 let nsError = error as NSError 283 let userInfo = nsError.userInfo 284 .map { "\($0.key)=\($0.value)" } 285 .joined(separator: " | ") 286 return "domain=\(nsError.domain) code=\(nsError.code) \(nsError.localizedDescription)\n\(userInfo)" 287 } 288 }