mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
ff5dcb040d
Removes the ServiceLocator singleton and instead directly injects the dependencies it was holding for more control over instantiation of the various component subtrees and better visibility over how they're being used.
214 lines
8.4 KiB
Swift
214 lines
8.4 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Compound
|
|
import SwiftUI
|
|
|
|
struct InviteUsersScreen: View {
|
|
@ObservedObject var context: InviteUsersScreenViewModel.Context
|
|
|
|
@State private var formWidth = CGFloat.zero
|
|
|
|
var showTopSection: Bool {
|
|
!context.viewState.selectedUsers.isEmpty || context.viewState.isSearching
|
|
}
|
|
|
|
var body: some View {
|
|
mainContent
|
|
.compoundList()
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.navigationTitle(L10n.screenCreateRoomAddPeopleTitle)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar { toolbar }
|
|
.searchController(query: $context.searchQuery,
|
|
placeholder: L10n.commonSearchForSomeone,
|
|
showsCancelButton: false,
|
|
disablesInteractiveDismiss: true,
|
|
accessibilityFocusOnStart: true)
|
|
.compoundSearchField()
|
|
.sheet(isPresented: $context.presentConfirmationDialog) {
|
|
InviteUsersConfirmationSheetView(context: context, users: context.viewState.usersToConfirm)
|
|
}
|
|
.alert(item: $context.alertInfo)
|
|
.navigationBarBackButtonHidden(context.viewState.isSkippable)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private var mainContent: some View {
|
|
Form {
|
|
if showTopSection {
|
|
// this is a fix for having the carousel not clipped, and inside the form, so when the search is dismissed, it wont break the design
|
|
Section {
|
|
EmptyView()
|
|
} header: {
|
|
VStack(spacing: 16) {
|
|
selectedUsersSection
|
|
.textCase(.none)
|
|
.frame(width: formWidth)
|
|
|
|
if context.viewState.isSearching {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if context.viewState.hasEmptySearchResults {
|
|
noResultsContent
|
|
} else {
|
|
usersSection
|
|
}
|
|
}
|
|
.readWidth($formWidth)
|
|
}
|
|
|
|
private var noResultsContent: some View {
|
|
Text(L10n.commonNoResults)
|
|
.font(.compound.bodyLG)
|
|
.foregroundColor(.compound.textSecondary)
|
|
.frame(maxWidth: .infinity)
|
|
.listRowBackground(Color.clear)
|
|
.accessibilityIdentifier(A11yIdentifiers.startChatScreen.searchNoResults)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var usersSection: some View {
|
|
if !context.viewState.usersSection.users.isEmpty {
|
|
Section {
|
|
ForEach(context.viewState.usersSection.users, id: \.userID) { user in
|
|
UserProfileListRow(user: user,
|
|
membership: context.viewState.membershipState(user),
|
|
mediaProvider: context.mediaProvider,
|
|
kind: .multiSelection(isSelected: context.viewState.isUserSelected(user)) {
|
|
context.send(viewAction: .toggleUser(user))
|
|
})
|
|
.disabled(context.viewState.isUserDisabled(user))
|
|
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.userProfile)
|
|
}
|
|
} header: {
|
|
if let title = context.viewState.usersSection.title {
|
|
Text(title)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
} else {
|
|
Section.empty
|
|
}
|
|
}
|
|
|
|
@ScaledMetric private var selectedUserCellWidth: CGFloat = 80
|
|
|
|
private var selectedUsersSection: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 8) {
|
|
ForEach(context.viewState.selectedUsers, id: \.userID) { user in
|
|
InviteUsersScreenSelectedItem(user: user, mediaProvider: context.mediaProvider) {
|
|
deselect(user)
|
|
}
|
|
.frame(width: selectedUserCellWidth)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.scrollTargetLayout()
|
|
}
|
|
.scrollPosition(id: $context.selectedUsersPosition, anchor: .trailing)
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbar: some ToolbarContent {
|
|
if !context.viewState.isSkippable {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button(L10n.actionCancel) {
|
|
context.send(viewAction: .cancel)
|
|
}
|
|
}
|
|
}
|
|
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(context.viewState.actionText) {
|
|
context.send(viewAction: .proceed)
|
|
}
|
|
.accessibilityIdentifier(A11yIdentifiers.inviteUsersScreen.proceed)
|
|
.disabled(context.viewState.isActionDisabled)
|
|
}
|
|
}
|
|
|
|
private func deselect(_ user: UserProfileProxy) {
|
|
context.send(viewAction: .toggleUser(user))
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct InviteUsersScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let viewModel = makeViewModel()
|
|
static let searchingViewModel = makeViewModel(searchQuery: "Alice")
|
|
static let selectedViewModel = makeViewModel(hasSelection: true)
|
|
static let confirmSelectedViewModel = makeViewModel(shouldConfirm: true)
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
InviteUsersScreen(context: viewModel.context)
|
|
}
|
|
.previewDisplayName("Suggestions")
|
|
.snapshotPreferences(expect: viewModel.context.$viewState.map { !$0.usersSection.users.isEmpty })
|
|
|
|
ElementNavigationStack {
|
|
InviteUsersScreen(context: searchingViewModel.context)
|
|
}
|
|
.previewDisplayName("Searching")
|
|
.snapshotPreferences(expect: searchingViewModel.context.$viewState.map {
|
|
$0.usersSection.type == .searchResult && !$0.usersSection.users.isEmpty
|
|
})
|
|
|
|
ElementNavigationStack {
|
|
InviteUsersScreen(context: selectedViewModel.context)
|
|
}
|
|
.previewDisplayName("Selected")
|
|
.snapshotPreferences(expect: selectedViewModel.context.$viewState.map { !$0.selectedUsers.isEmpty })
|
|
|
|
ElementNavigationStack {
|
|
InviteUsersScreen(context: confirmSelectedViewModel.context)
|
|
}
|
|
.previewDisplayName("Confirm Selected")
|
|
}
|
|
|
|
static func makeViewModel(searchQuery: String? = nil, hasSelection: Bool = false, shouldConfirm: Bool = false) -> InviteUsersScreenViewModel {
|
|
let clientProxy = ClientProxyMock(.init())
|
|
clientProxy.recentConversationCounterpartsReturnValue = [.mockAlice, .mockBob, .mockCharlie, .mockDan, .mockVerbose]
|
|
|
|
let userDiscoveryService = UserDiscoveryServiceMock()
|
|
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
|
|
|
|
let viewModel = InviteUsersScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
|
|
roomProxy: JoinedRoomProxyMock(.init(members: [])),
|
|
isSkippable: true,
|
|
userDiscoveryService: userDiscoveryService,
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
appSettings: AppSettings())
|
|
|
|
if let searchQuery {
|
|
viewModel.context.searchQuery = searchQuery
|
|
}
|
|
|
|
if hasSelection {
|
|
viewModel.state.selectedUsers = [.mockAlice]
|
|
}
|
|
|
|
if shouldConfirm {
|
|
viewModel.state.usersToConfirm = [.mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice, .mockAlice]
|
|
viewModel.state.bindings.presentConfirmationDialog = true
|
|
}
|
|
|
|
return viewModel
|
|
}
|
|
}
|