crossmate

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

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 }