crossmate

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

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 }