crossmate

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

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 }