FriendPickerView.swift (4411B)
1 import SwiftUI 2 3 /// Lists existing (non-blocked) friends so the user can re-invite one to a 4 /// game without generating and sending a link. Friends are accumulated 5 /// automatically the first time you collaborate with someone (see 6 /// `FriendController`). 7 struct FriendPickerView: View { 8 let gameID: UUID 9 10 @Environment(\.inviteFriend) private var inviteFriend 11 @Environment(\.dismiss) private var dismiss 12 13 @FetchRequest( 14 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 15 predicate: NSPredicate(format: "isBlocked == NO"), 16 animation: .default 17 ) 18 private var friends: FetchedResults<FriendEntity> 19 20 @State private var invitingAuthorID: String? 21 @State private var invitedAuthorIDs: Set<String> = [] 22 @State private var isInviteLimitReached: Bool 23 @State private var errorMessage: String? 24 25 init(gameID: UUID, isInviteLimitReached: Bool = false) { 26 self.gameID = gameID 27 _isInviteLimitReached = State(initialValue: isInviteLimitReached) 28 } 29 30 var body: some View { 31 List { 32 Section { 33 if friends.isEmpty { 34 Text("No Prior Crossmates") 35 .font(.body.weight(.medium)) 36 .foregroundStyle(.secondary) 37 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 38 } else { 39 ForEach(friends, id: \.authorID) { friend in 40 friendRow(for: friend) 41 } 42 } 43 } header: { 44 Text("Tap a player to add and notify. The player chooses whether to accept from their Invited list.") 45 .font(.footnote) 46 .foregroundStyle(Color(.secondaryLabel)) 47 .textCase(nil) 48 .padding(.bottom, 8) 49 } 50 51 if let errorMessage { 52 Section("Error") { 53 Text(errorMessage) 54 .font(.caption.monospaced()) 55 .foregroundStyle(.red) 56 .textSelection(.enabled) 57 } 58 } 59 } 60 .navigationTitle("Invite a Crossmate") 61 .navigationBarTitleDisplayMode(.inline) 62 } 63 64 @ViewBuilder 65 private func friendRow(for friend: FriendEntity) -> some View { 66 let authorID = friend.authorID ?? "" 67 let invited = invitedAuthorIDs.contains(authorID) 68 Button { 69 Task { await invite(authorID) } 70 } label: { 71 HStack { 72 FriendAvatarView( 73 authorID: authorID, 74 invitePhase: invitePhase(authorID: authorID, invited: invited) 75 ) 76 .padding(.trailing, 8) 77 Text(friend.resolvedDisplayName) 78 Spacer() 79 } 80 } 81 .disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited)) 82 } 83 84 /// Maps the row's invite state to the avatar's animation phase. Sending 85 /// is checked first so the glyph keeps wiggling right up until the work 86 /// finishes, then resolves straight into the spin-to-checkmark. 87 private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { 88 if invitingAuthorID == authorID { return .sending } 89 if invited { return .sent } 90 return nil 91 } 92 93 private func invite(_ authorID: String) async { 94 guard !authorID.isEmpty, let inviteFriend else { return } 95 withAnimation(.snappy) { invitingAuthorID = authorID } 96 errorMessage = nil 97 defer { withAnimation(.snappy) { invitingAuthorID = nil } } 98 do { 99 try await inviteFriend(gameID, authorID) 100 withAnimation(.snappy) { 101 _ = invitedAuthorIDs.insert(authorID) 102 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 103 } 104 } catch { 105 if case ShareController.ShareError.collaborationLimitReached = error { 106 withAnimation(.snappy) { isInviteLimitReached = true } 107 } 108 errorMessage = describe(error) 109 } 110 } 111 112 private func describe(_ error: Error) -> String { 113 (error as? LocalizedError)?.errorDescription ?? String(describing: error) 114 } 115 }