crossmate

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

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 }