Skip to content

Commit 3954012

Browse files
Added tests
1 parent 2bd85f8 commit 3954012

17 files changed

+561
-155
lines changed

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersView.swift

Lines changed: 162 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,98 +5,202 @@
55
import StreamChat
66
import SwiftUI
77

8-
/// View for the add users popup.
8+
/// Full-sheet view for adding members to a channel.
9+
/// Supports search, pagination, and multi-select with a batch confirm action.
910
public struct AddUsersView<Factory: ViewFactory>: View {
10-
@Injected(\.fonts) private var fonts
1111
@Injected(\.colors) private var colors
1212

13-
private let columns = Array(
14-
repeating:
15-
GridItem(
16-
.adaptive(minimum: 64),
17-
alignment: .top
18-
),
19-
count: 4
20-
)
21-
22-
private let factory: Factory
13+
@Environment(\.presentationMode) private var presentationMode
2314

15+
private let factory: Factory
2416
@StateObject private var viewModel: AddUsersViewModel
25-
var onUserTap: (ChatUser) -> Void
17+
var onConfirm: ([ChatUser]) -> Void
2618

2719
public init(
2820
factory: Factory = DefaultViewFactory.shared,
2921
loadedUserIds: [String],
30-
onUserTap: @escaping (ChatUser) -> Void
22+
onConfirm: @escaping ([ChatUser]) -> Void
3123
) {
3224
_viewModel = StateObject(
3325
wrappedValue: AddUsersViewModel(loadedUserIds: loadedUserIds)
3426
)
35-
self.onUserTap = onUserTap
27+
self.onConfirm = onConfirm
3628
self.factory = factory
3729
}
3830

3931
init(
4032
factory: Factory = DefaultViewFactory.shared,
4133
viewModel: AddUsersViewModel,
42-
onUserTap: @escaping (ChatUser) -> Void
34+
onConfirm: @escaping ([ChatUser]) -> Void
4335
) {
44-
_viewModel = StateObject(
45-
wrappedValue: viewModel
46-
)
47-
self.onUserTap = onUserTap
36+
_viewModel = StateObject(wrappedValue: viewModel)
37+
self.onConfirm = onConfirm
4838
self.factory = factory
4939
}
5040

5141
public var body: some View {
52-
VStack {
53-
SearchBar(text: $viewModel.searchText)
54-
55-
ScrollView {
56-
LazyVGrid(columns: columns, alignment: .center, spacing: 0) {
57-
ForEach(viewModel.users) { user in
58-
Button {
59-
onUserTap(user)
60-
} label: {
61-
VStack {
62-
let itemSize: CGFloat = 64
63-
factory.makeUserAvatarView(
64-
options: UserAvatarViewOptions(
65-
user: user,
66-
size: itemSize,
67-
showsIndicator: false
68-
)
69-
)
70-
71-
Text(user.name ?? user.id)
72-
.multilineTextAlignment(.center)
73-
.lineLimit(2)
74-
.font(fonts.footnoteBold)
75-
.frame(width: itemSize)
76-
.foregroundColor(Color(colors.text))
42+
NavigationView {
43+
VStack(spacing: 0) {
44+
SearchBar(text: $viewModel.searchText)
45+
46+
ScrollView {
47+
LazyVStack(spacing: 0) {
48+
ForEach(viewModel.users) { user in
49+
AddMembersUserRow(
50+
factory: factory,
51+
user: user,
52+
isSelected: viewModel.isSelected(user),
53+
isAlreadyMember: viewModel.isAlreadyMember(user)
54+
) {
55+
viewModel.toggleUser(user)
56+
}
57+
.onAppear {
58+
viewModel.onUserAppear(user)
7759
}
78-
.padding(.all, 8)
79-
}
80-
.onAppear {
81-
viewModel.onUserAppear(user)
8260
}
8361
}
8462
}
8563
}
86-
.frame(maxHeight: 240)
64+
.background(Color(colors.backgroundCoreApp).edgesIgnoringSafeArea(.all))
65+
.modifier(
66+
AddMembersToolbarModifier(
67+
viewModel: viewModel,
68+
onConfirm: { onConfirm(viewModel.selectedUsers) },
69+
onDismiss: { presentationMode.wrappedValue.dismiss() }
70+
)
71+
)
72+
.navigationBarTitleDisplayMode(.inline)
8773
}
88-
.standardPadding()
89-
.background(Color(colors.background))
90-
.cornerRadius(16)
91-
.padding()
9274
}
9375
}
9476

9577
/// Options used in the add users view.
9678
public final class AddUsersOptions: Sendable {
97-
public let loadedUsers: [ChatUser]
98-
99-
public init(loadedUsers: [ChatUser]) {
100-
self.loadedUsers = loadedUsers
79+
public let loadedUserIds: [String]
80+
81+
public init(loadedUserIds: [String]) {
82+
self.loadedUserIds = loadedUserIds
83+
}
84+
}
85+
86+
// MARK: - User Row
87+
88+
private struct AddMembersUserRow<Factory: ViewFactory>: View {
89+
@Injected(\.colors) private var colors
90+
@Injected(\.fonts) private var fonts
91+
@Injected(\.tokens) private var tokens
92+
93+
let factory: Factory
94+
let user: ChatUser
95+
let isSelected: Bool
96+
let isAlreadyMember: Bool
97+
let onTap: () -> Void
98+
99+
var body: some View {
100+
Button(action: { if !isAlreadyMember { onTap() } }) {
101+
HStack(spacing: tokens.spacingSm) {
102+
factory.makeUserAvatarView(
103+
options: UserAvatarViewOptions(
104+
user: user,
105+
size: AvatarSize.large,
106+
showsIndicator: false
107+
)
108+
)
109+
110+
VStack(alignment: .leading, spacing: 2) {
111+
Text(user.name ?? user.id)
112+
.font(fonts.body)
113+
.foregroundColor(Color(colors.textPrimary))
114+
.lineLimit(1)
115+
116+
if isAlreadyMember {
117+
Text(L10n.ChatInfo.Members.alreadyMember)
118+
.font(fonts.footnote)
119+
.foregroundColor(Color(colors.textLowEmphasis))
120+
}
121+
}
122+
123+
Spacer()
124+
125+
if !isAlreadyMember {
126+
selectionIndicator
127+
}
128+
}
129+
.padding(.horizontal, tokens.spacingMd)
130+
.padding(.vertical, tokens.spacingXs)
131+
.background(Color(colors.backgroundCoreApp))
132+
.contentShape(.rect)
133+
}
134+
.buttonStyle(.plain)
135+
}
136+
137+
private var selectionIndicator: some View {
138+
ZStack {
139+
if isSelected {
140+
Circle()
141+
.fill(Color(colors.accentPrimary))
142+
.frame(width: 24, height: 24)
143+
Image(systemName: "checkmark")
144+
.font(.system(size: 12, weight: .bold))
145+
.foregroundColor(.white)
146+
} else {
147+
Circle()
148+
.strokeBorder(Color(colors.borderCoreSubtle), lineWidth: 1.5)
149+
.frame(width: 24, height: 24)
150+
}
151+
}
152+
}
153+
}
154+
155+
// MARK: - Toolbar
156+
157+
private struct AddMembersToolbarModifier: ViewModifier {
158+
@Injected(\.colors) private var colors
159+
@Injected(\.fonts) private var fonts
160+
@Injected(\.images) private var images
161+
@Injected(\.tokens) private var tokens
162+
163+
@ObservedObject var viewModel: AddUsersViewModel
164+
let onConfirm: () -> Void
165+
let onDismiss: () -> Void
166+
167+
func body(content: Content) -> some View {
168+
if #available(iOS 26.0, *) {
169+
content
170+
.toolbarThemed {
171+
toolbarContent()
172+
#if compiler(>=6.2)
173+
.sharedBackgroundVisibility(.hidden)
174+
#endif
175+
}
176+
} else {
177+
content
178+
.toolbarThemed {
179+
toolbarContent()
180+
}
181+
}
182+
}
183+
184+
@ToolbarContentBuilder private func toolbarContent() -> some ToolbarContent {
185+
ToolbarItem(placement: .principal) {
186+
Text(L10n.ChatInfo.Members.addMembersTitle)
187+
.font(fonts.bodyBold)
188+
.foregroundColor(Color(colors.navigationBarTitle))
189+
}
190+
ToolbarItem(placement: .navigationBarLeading) {
191+
Button(action: onDismiss) {
192+
Image(uiImage: images.close)
193+
.foregroundColor(Color(colors.textSecondary))
194+
}
195+
}
196+
ToolbarItem(placement: .navigationBarTrailing) {
197+
Button(action: onConfirm) {
198+
Image(systemName: "checkmark")
199+
.font(.system(size: 12, weight: .bold))
200+
.foregroundColor(.white)
201+
.frame(width: tokens.iconSizeLg, height: tokens.iconSizeLg)
202+
.background(Circle().fill(Color(colors.accentPrimary)))
203+
}
204+
}
101205
}
102206
}

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/AddUsersViewModel.swift

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,55 @@ import SwiftUI
1111
@Injected(\.chatClient) private var chatClient
1212

1313
@Published var users = [ChatUser]()
14-
@Published var searchText = "" {
15-
didSet {
16-
searchUsers(term: searchText)
17-
}
18-
}
14+
@Published var searchText = ""
15+
@Published private(set) var selectedUserIds = Set<String>()
1916

2017
private var loadedUserIds: [String]
2118
private var loadingNextUsers = false
19+
private var cancellables = Set<AnyCancellable>()
2220
private lazy var searchController: ChatUserSearchController = chatClient.userSearchController()
2321

2422
init(loadedUserIds: [String]) {
2523
self.loadedUserIds = loadedUserIds
2624
searchUsers()
25+
observeSearchText()
2726
}
2827

2928
init(loadedUserIds: [String], searchController: ChatUserSearchController) {
3029
self.loadedUserIds = loadedUserIds
3130
self.searchController = searchController
3231
searchUsers()
32+
observeSearchText()
33+
}
34+
35+
private func observeSearchText() {
36+
$searchText
37+
.dropFirst()
38+
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
39+
.sink { [weak self] term in
40+
self?.searchUsers(term: term)
41+
}
42+
.store(in: &cancellables)
43+
}
44+
45+
func toggleUser(_ user: ChatUser) {
46+
if selectedUserIds.contains(user.id) {
47+
selectedUserIds.remove(user.id)
48+
} else {
49+
selectedUserIds.insert(user.id)
50+
}
51+
}
52+
53+
func isSelected(_ user: ChatUser) -> Bool {
54+
selectedUserIds.contains(user.id)
55+
}
56+
57+
func isAlreadyMember(_ user: ChatUser) -> Bool {
58+
loadedUserIds.contains(user.id)
59+
}
60+
61+
var selectedUsers: [ChatUser] {
62+
users.filter { selectedUserIds.contains($0.id) }
3363
}
3464

3565
func onUserAppear(_ user: ChatUser) {
@@ -47,7 +77,7 @@ import SwiftUI
4777
loadingNextUsers = true
4878
searchController.loadNextUsers { [weak self] _ in
4979
guard let self else { return }
50-
users = searchController.userArray
80+
users = deduplicated(searchController.userArray)
5181
loadingNextUsers = false
5282
}
5383
}
@@ -56,18 +86,23 @@ import SwiftUI
5686
private func searchUsers() {
5787
searchController.search(query: UserListQuery()) { [weak self] error in
5888
guard let self, error == nil else { return }
59-
users = searchController.userArray.filter { user in
60-
!self.loadedUserIds.contains(user.id)
61-
}
89+
users = deduplicated(searchController.userArray)
6290
}
6391
}
6492

6593
private func searchUsers(term: String) {
66-
searchController.search(term: searchText) { [weak self] error in
94+
if term.isEmpty {
95+
searchUsers()
96+
return
97+
}
98+
searchController.search(term: term) { [weak self] error in
6799
guard let self, error == nil else { return }
68-
users = searchController.userArray.filter { user in
69-
!self.loadedUserIds.contains(user.id)
70-
}
100+
users = deduplicated(searchController.userArray)
71101
}
72102
}
103+
104+
private func deduplicated(_ users: [ChatUser]) -> [ChatUser] {
105+
var seen = Set<String>()
106+
return users.filter { seen.insert($0.id).inserted }
107+
}
73108
}

0 commit comments

Comments
 (0)