FriendsView.swift (4456B)
1 import SwiftUI 2 3 /// Sheet listing the user's crossmates (friends), presented from the game 4 /// list. Friends are accumulated automatically the first time you collaborate 5 /// with someone (see `FriendController`); the actions are renaming (a private 6 /// nickname via `\.renameFriend`) and blocking, which tears down the pairwise 7 /// channel via `\.blockFriend`. 8 struct FriendsView: View { 9 @Environment(\.blockFriend) private var blockFriend 10 @Environment(\.renameFriend) private var renameFriend 11 @Environment(\.dismiss) private var dismiss 12 13 @FetchRequest( 14 sortDescriptors: [NSSortDescriptor(keyPath: \FriendEntity.createdAt, ascending: true)], 15 predicate: NSPredicate(format: "isBlocked == NO") 16 ) 17 private var friends: FetchedResults<FriendEntity> 18 19 @State private var blockTarget: FriendEntity? 20 @State private var renameTarget: FriendEntity? 21 @State private var renameText = "" 22 23 var body: some View { 24 NavigationStack { 25 List { 26 ForEach(friends, id: \.authorID) { friend in 27 friendRow(for: friend) 28 } 29 } 30 .overlay { 31 if friends.isEmpty { 32 ContentUnavailableView { 33 Label("No Crossmates", systemImage: "person.2") 34 } description: { 35 Text("Crossmates are added automatically when you start a shared puzzle with someone.") 36 } 37 } 38 } 39 .navigationTitle("Crossmates") 40 .navigationBarTitleDisplayMode(.inline) 41 .toolbar { 42 ToolbarItem(placement: .cancellationAction) { 43 Button { 44 dismiss() 45 } label: { 46 Image(systemName: "xmark") 47 } 48 .accessibilityLabel("Cancel") 49 } 50 } 51 .alert("Rename This Player?", isPresented: .init( 52 get: { renameTarget != nil }, 53 set: { if !$0 { renameTarget = nil } } 54 )) { 55 TextField("Name", text: $renameText) 56 Button("Rename") { 57 if let authorID = renameTarget?.authorID { 58 let nickname = renameText 59 Task { await renameFriend?(authorID, nickname) } 60 } 61 } 62 Button("Cancel", role: .cancel) {} 63 } message: { 64 Text("Only you will see this name. Leave it blank to use the name they chose.") 65 } 66 .alert("Block This Player?", isPresented: .init( 67 get: { blockTarget != nil }, 68 set: { if !$0 { blockTarget = nil } } 69 )) { 70 Button("Block", role: .destructive) { 71 if let authorID = blockTarget?.authorID { 72 Task { await blockFriend?(authorID) } 73 } 74 } 75 Button("Cancel", role: .cancel) {} 76 } message: { 77 let name = blockTarget?.resolvedDisplayName ?? "this player" 78 Text("You won't receive further invites from \(name), and any games they currently share with you will be removed from this device.") 79 } 80 } 81 } 82 83 @ViewBuilder 84 private func friendRow(for friend: FriendEntity) -> some View { 85 HStack { 86 FriendAvatarView(authorID: friend.authorID ?? "") 87 .padding(.trailing, 8) 88 Text(friend.resolvedDisplayName) 89 Spacer() 90 Menu { 91 Button { 92 renameText = friend.nickname ?? "" 93 renameTarget = friend 94 } label: { 95 Label("Rename", systemImage: "character.cursor.ibeam") 96 } 97 Button(role: .destructive) { blockTarget = friend } label: { 98 Label("Block", systemImage: "hand.raised") 99 } 100 } label: { 101 Image(systemName: "ellipsis") 102 .font(.body) 103 .frame(width: 32, height: 32) 104 .contentShape(Rectangle()) 105 } 106 .tint(.secondary) 107 .compositingGroup() 108 } 109 .swipeActions(edge: .trailing) { 110 Button("Block", role: .destructive) { blockTarget = friend } 111 } 112 } 113 }