FriendPickerView.swift (5270B)
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 let shareController: ShareController 10 11 @Environment(\.inviteFriend) private var inviteFriend 12 @Environment(\.dismiss) private var dismiss 13 14 @FetchRequest( 15 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 16 predicate: NSPredicate(format: "isBlocked == NO"), 17 animation: .default 18 ) 19 private var friends: FetchedResults<FriendEntity> 20 21 @State private var invitingAuthorID: String? 22 @State private var invitedAuthorIDs: Set<String> 23 @State private var isInviteLimitReached: Bool 24 @State private var errorMessage: String? 25 26 init(gameID: UUID, shareController: ShareController, isInviteLimitReached: Bool = false) { 27 self.gameID = gameID 28 self.shareController = shareController 29 // Seed from the in-memory session set so friends invited a moment ago 30 // already wear their checkmark on the first frame; the .task below 31 // backfills anyone invited in a prior session or on another device. 32 _invitedAuthorIDs = State( 33 initialValue: shareController.invitedAuthorIDsKnownThisSession(for: gameID) 34 ) 35 _isInviteLimitReached = State(initialValue: isInviteLimitReached) 36 } 37 38 var body: some View { 39 List { 40 Section { 41 if friends.isEmpty { 42 Text("No Prior Crossmates") 43 .font(.body.weight(.medium)) 44 .foregroundStyle(.secondary) 45 .frame(maxWidth: .infinity, minHeight: 72, alignment: .center) 46 } else { 47 ForEach(friends, id: \.authorID) { friend in 48 friendRow(for: friend) 49 } 50 } 51 } header: { 52 Text("Tap a player to add and notify. The player chooses whether to accept from their Invited list.") 53 .font(.footnote) 54 .foregroundStyle(Color(.secondaryLabel)) 55 .textCase(nil) 56 .padding(.bottom, 8) 57 } 58 59 if let errorMessage { 60 Section("Error") { 61 Text(errorMessage) 62 .font(.caption.monospaced()) 63 .foregroundStyle(.red) 64 .textSelection(.enabled) 65 } 66 } 67 } 68 .navigationTitle("Invite a Crossmate") 69 .navigationBarTitleDisplayMode(.inline) 70 .task { 71 // Reflect friends already on the share so re-opening the picker 72 // shows their checkmark instead of an un-invited glyph. 73 let invited = await shareController.invitedAuthorIDs(for: gameID) 74 if !invited.isEmpty { 75 withAnimation(.snappy) { invitedAuthorIDs.formUnion(invited) } 76 } 77 } 78 } 79 80 @ViewBuilder 81 private func friendRow(for friend: FriendEntity) -> some View { 82 let authorID = friend.authorID ?? "" 83 let invited = invitedAuthorIDs.contains(authorID) 84 Button { 85 Task { await invite(authorID) } 86 } label: { 87 HStack { 88 FriendAvatarView( 89 authorID: authorID, 90 invitePhase: invitePhase(authorID: authorID, invited: invited) 91 ) 92 .padding(.trailing, 8) 93 Text(friend.resolvedDisplayName) 94 Spacer() 95 } 96 } 97 .disabled(authorID.isEmpty || invitingAuthorID != nil || invited || (isInviteLimitReached && !invited)) 98 } 99 100 /// Maps the row's invite state to the avatar's animation phase. Sending 101 /// is checked first so the glyph keeps wiggling right up until the work 102 /// finishes, then resolves straight into the spin-to-checkmark. 103 private func invitePhase(authorID: String, invited: Bool) -> FriendAvatarView.InvitePhase? { 104 if invitingAuthorID == authorID { return .sending } 105 if invited { return .sent } 106 return nil 107 } 108 109 private func invite(_ authorID: String) async { 110 guard !authorID.isEmpty, let inviteFriend else { return } 111 withAnimation(.snappy) { invitingAuthorID = authorID } 112 errorMessage = nil 113 defer { withAnimation(.snappy) { invitingAuthorID = nil } } 114 do { 115 try await inviteFriend(gameID, authorID) 116 withAnimation(.snappy) { 117 _ = invitedAuthorIDs.insert(authorID) 118 isInviteLimitReached = invitedAuthorIDs.count >= ShareController.maximumPeoplePerPuzzle - 1 119 } 120 } catch { 121 if case ShareController.ShareError.collaborationLimitReached = error { 122 withAnimation(.snappy) { isInviteLimitReached = true } 123 } 124 errorMessage = describe(error) 125 } 126 } 127 128 private func describe(_ error: Error) -> String { 129 (error as? LocalizedError)?.errorDescription ?? String(describing: error) 130 } 131 }