From 2b6392e635428a987b003fdc61c0dbb72c7868dd Mon Sep 17 00:00:00 2001 From: Flescio Date: Mon, 17 Apr 2023 10:29:30 +0200 Subject: [PATCH] Select members before creating a room (UI for selection) (#766) * add invite users in room flow * add selectable user cell * add screenshoots * add unit test * add button style * add scroll to bottom when added user * rebase screenshot from develop * add changelog * rename invite users files * fix static viewModel * fix buttonStyle, fix unit test * UI test for selected user * remove fixed height size * fix saled width for horizontal cell * add usersSection, screenshots * use publishers pattern * add selectable form button style * add comment * improve readability * some rename on InviteUsers * fix compile issues * add search content --- ElementX/Sources/Other/AvatarSize.swift | 3 + .../Form Styles/FormButtonStyles.swift | 9 ++ .../InviteUsers/InviteUsersCoordinator.swift | 58 ++++++++ .../InviteUsers/InviteUsersModels.swift | 56 ++++++++ .../InviteUsers/InviteUsersViewModel.swift | 75 ++++++++++ .../InviteUsersViewModelProtocol.swift | 23 +++ .../InviteUsers/View/InviteUsersScreen.swift | 132 ++++++++++++++++++ .../View/InviteUsersSelectedItem.swift | 50 +++++++ .../SearchUsersSection.swift | 37 +++++ .../UserProfileCell.swift} | 2 +- .../StartChat/StartChatCoordinator.swift | 35 ++++- .../Screens/StartChat/StartChatModels.swift | 21 +-- .../StartChat/StartChatViewModel.swift | 16 ++- .../StartChatViewModelProtocol.swift | 4 +- .../StartChat/View/StartChatScreen.swift | 3 +- .../UserSessionFlowCoordinator.swift | 7 +- .../UITests/UITestsAppCoordinator.swift | 11 +- .../UITests/UITestsScreenIdentifier.swift | 1 + .../Sources/InviteUsersScreenUITests.swift | 31 ++++ ...n-GB-iPad-9th-generation.inviteUsers-1.png | 3 + .../en-GB-iPad-9th-generation.inviteUsers.png | 3 + ...GB-iPad-9th-generation.reportContent-1.png | 3 + .../en-GB-iPhone-14.inviteUsers-1.png | 3 + .../en-GB-iPhone-14.inviteUsers.png | 3 + .../en-GB-iPhone-14.reportContent-1.png | 3 + ...eudo-iPad-9th-generation.inviteUsers-1.png | 3 + ...pseudo-iPad-9th-generation.inviteUsers.png | 3 + ...do-iPad-9th-generation.reportContent-1.png | 3 + .../pseudo-iPhone-14.inviteUsers-1.png | 3 + .../pseudo-iPhone-14.inviteUsers.png | 3 + .../pseudo-iPhone-14.reportContent-1.png | 3 + .../Sources/InviteUsersViewModelTests.swift | 62 ++++++++ .../Sources/StartChatViewModelTests.swift | 4 +- changelog.d/766.feature | 1 + 34 files changed, 634 insertions(+), 43 deletions(-) create mode 100644 ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift create mode 100644 ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift create mode 100644 ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift create mode 100644 ElementX/Sources/Screens/InviteUsers/InviteUsersViewModelProtocol.swift create mode 100644 ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift create mode 100644 ElementX/Sources/Screens/InviteUsers/View/InviteUsersSelectedItem.swift create mode 100644 ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift rename ElementX/Sources/Screens/{StartChat/View/StartChatSuggestedUserCell.swift => SearchUsersSection/UserProfileCell.swift} (98%) create mode 100644 UITests/Sources/InviteUsersScreenUITests.swift create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.reportContent-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png create mode 100644 UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.reportContent-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.reportContent-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png create mode 100644 UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.reportContent-1.png create mode 100644 UnitTests/Sources/InviteUsersViewModelTests.swift create mode 100644 changelog.d/766.feature diff --git a/ElementX/Sources/Other/AvatarSize.swift b/ElementX/Sources/Other/AvatarSize.swift index b47199974..351a30246 100644 --- a/ElementX/Sources/Other/AvatarSize.swift +++ b/ElementX/Sources/Other/AvatarSize.swift @@ -48,6 +48,7 @@ enum UserAvatarSizeOnScreen { case roomDetails case startChat case memberDetails + case inviteUsers var value: CGFloat { switch self { @@ -63,6 +64,8 @@ enum UserAvatarSizeOnScreen { return 36 case .memberDetails: return 70 + case .inviteUsers: + return 56 } } } diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift index 47fd65a92..6a93bb514 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift @@ -20,6 +20,7 @@ import SwiftUI enum FormRowAccessory: View { case navigationLink case progressView + case selection(isSelected: Bool) var body: some View { switch self { @@ -29,6 +30,10 @@ enum FormRowAccessory: View { .foregroundColor(.element.quaternaryContent) case .progressView: ProgressView() + case .selection(let isSelected): + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.element.body) + .foregroundColor(isSelected ? .element.primaryContent : .element.tertiaryContent) } } } @@ -124,6 +129,10 @@ struct FormButtonStyles_Previews: PreviewProvider { Text("Hello world") } .buttonStyle(FormButtonStyle()) + Button { } label: { + Text("Selection") + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: false))) } .formSectionStyle() diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift new file mode 100644 index 000000000..a483d68a6 --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersCoordinator.swift @@ -0,0 +1,58 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +struct InviteUsersCoordinatorParameters { + let userSession: UserSessionProtocol +} + +enum InviteUsersCoordinatorAction { + case close +} + +final class InviteUsersCoordinator: CoordinatorProtocol { + private let parameters: InviteUsersCoordinatorParameters + private let viewModel: InviteUsersViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: InviteUsersCoordinatorParameters) { + self.parameters = parameters + + viewModel = InviteUsersViewModel(userSession: parameters.userSession) + } + + func start() { + viewModel.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .close: + self.actionsSubject.send(.close) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(InviteUsersScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift new file mode 100644 index 000000000..b4b3f5383 --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersModels.swift @@ -0,0 +1,56 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum InviteUsersViewModelAction { + case close +} + +struct InviteUsersViewState: BindableState { + var bindings = InviteUsersViewStateBindings() + + var usersSection: SearchUsersSection = .init(type: .empty, users: []) + var selectedUsers: [UserProfile] = [] + + var isSearching: Bool { + !bindings.searchQuery.isEmpty + } + + var hasEmptySearchResults: Bool { + isSearching && usersSection.type == .searchResult && usersSection.users.isEmpty + } + + var scrollToLastID: String? + + func isUserSelected(_ user: UserProfile) -> Bool { + selectedUsers.contains { $0.userID == user.userID } + } +} + +struct InviteUsersViewStateBindings { + var searchQuery = "" + + /// Information describing the currently displayed alert. + var alertInfo: AlertInfo? +} + +enum InviteUsersViewAction { + case close + case proceed + case tapUser(UserProfile) + case deselectUser(UserProfile) +} diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift new file mode 100644 index 000000000..a080a3d5b --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModel.swift @@ -0,0 +1,75 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftUI + +typealias InviteUsersViewModelType = StateStoreViewModel + +class InviteUsersViewModel: InviteUsersViewModelType, InviteUsersViewModelProtocol { + private let userSession: UserSessionProtocol + private let actionsSubject: PassthroughSubject = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(userSession: UserSessionProtocol) { + self.userSession = userSession + super.init(initialViewState: InviteUsersViewState(), imageProvider: userSession.mediaProvider) + + fetchSuggestions() + } + + // MARK: - Public + + override func process(viewAction: InviteUsersViewAction) { + switch viewAction { + case .close: + actionsSubject.send(.close) + case .proceed: + break + case .tapUser(let user): + if state.isUserSelected(user) { + deselect(user) + } else { + select(user) + } + case .deselectUser(let user): + deselect(user) + } + } + + private func select(_ user: UserProfile) { + state.selectedUsers.append(user) + state.scrollToLastID = user.userID + } + + private func deselect(_ user: UserProfile) { + state.selectedUsers.removeAll(where: { $0.userID == user.userID }) + state.scrollToLastID = nil + } + + // MARK: - Private + + private func fetchSuggestions() { + guard ServiceLocator.shared.settings.startChatUserSuggestionsEnabled else { + state.usersSection = .init(type: .empty, users: []) + return + } + state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) + } +} diff --git a/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModelProtocol.swift b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModelProtocol.swift new file mode 100644 index 000000000..67d440b37 --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/InviteUsersViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine + +@MainActor +protocol InviteUsersViewModelProtocol { + var actions: AnyPublisher { get } + var context: InviteUsersViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift new file mode 100644 index 000000000..5c1984fc5 --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersScreen.swift @@ -0,0 +1,132 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct InviteUsersScreen: View { + @ObservedObject var context: InviteUsersViewModel.Context + + var body: some View { + VStack { + if !context.viewState.selectedUsers.isEmpty { + selectedUsersSection + } + searchContent + } + .scrollContentBackground(.hidden) + .background(Color.element.formBackground.ignoresSafeArea()) + .navigationTitle(L10n.screenCreateRoomActionInvitePeople) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + nextButton + } + } + .searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: L10n.commonSearchForSomeone) + .alert(item: $context.alertInfo) { $0.alert } + } + + // MARK: - Private + + /// The content shown in the form when a search query has been entered. + @ViewBuilder + private var searchContent: some View { + if context.viewState.hasEmptySearchResults { + noResultsContent + } else { + Form { + usersSection + } + } + } + + private var noResultsContent: some View { + Text(L10n.commonNoResults) + .font(.element.body) + .foregroundColor(.element.tertiaryContent) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .accessibilityIdentifier(A11yIdentifiers.startChatScreen.searchNoResults) + } + + private var usersSection: some View { + Section { + ForEach(context.viewState.usersSection.users, id: \.userID) { user in + Button { context.send(viewAction: .tapUser(user)) } label: { + UserProfileCell(user: user, + imageProvider: context.imageProvider) + } + .buttonStyle(FormButtonStyle(accessory: .selection(isSelected: context.viewState.isUserSelected(user)))) + } + } header: { + if let title = context.viewState.usersSection.type.title { + Text(title) + } + } + .listRowSeparator(.automatic) + .formSectionStyle() + } + + @ScaledMetric private var cellWidth: CGFloat = 64 + private var selectedUsersSection: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { scrollView in + HStack(spacing: 28) { + ForEach(context.viewState.selectedUsers, id: \.userID) { user in + InviteUsersSelectedItem(user: user, imageProvider: context.imageProvider) { + deselect(user) + } + .frame(width: cellWidth) + } + } + .onChange(of: context.viewState.scrollToLastID) { lastAddedID in + guard let id = lastAddedID else { return } + withAnimation(.easeInOut) { + scrollView.scrollTo(id) + } + } + .padding(.horizontal, 18) + } + } + } + + private var nextButton: some View { + Button { context.send(viewAction: .proceed) } label: { + Text(context.viewState.selectedUsers.isEmpty ? L10n.actionSkip : L10n.actionNext) + } + } + + private func deselect(_ user: UserProfile) { + context.send(viewAction: .deselectUser(user)) + } +} + +// MARK: - Previews + +struct InviteUsersScreen_Previews: PreviewProvider { + static let viewModel = { + let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"), + mediaProvider: MockMediaProvider()) + return InviteUsersViewModel(userSession: userSession) + }() + + static var previews: some View { + NavigationView { + InviteUsersScreen(context: viewModel.context) + .tint(.element.accent) + } + } +} diff --git a/ElementX/Sources/Screens/InviteUsers/View/InviteUsersSelectedItem.swift b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersSelectedItem.swift new file mode 100644 index 000000000..26488bb6f --- /dev/null +++ b/ElementX/Sources/Screens/InviteUsers/View/InviteUsersSelectedItem.swift @@ -0,0 +1,50 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct InviteUsersSelectedItem: View { + let user: UserProfile + let imageProvider: ImageProviderProtocol? + let dismissAction: () -> Void + + var body: some View { + VStack(spacing: 4) { + LoadableAvatarImage(url: user.avatarURL, + name: user.displayName, + contentID: user.userID, + avatarSize: .user(on: .inviteUsers), + imageProvider: imageProvider) + Text(user.displayName ?? user.userID) + .font(.element.title3) + .foregroundColor(.element.primaryContent) + .lineLimit(1) + } + .overlay(alignment: .topTrailing) { + Button(action: dismissAction) { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.palette) + .foregroundStyle(Color.element.systemPrimaryBackground, Color.element.primaryContent) + } + } + } +} + +struct InviteUsersSelectedItem_Previews: PreviewProvider { + static var previews: some View { + InviteUsersSelectedItem(user: .mockAlice, imageProvider: MockMediaProvider(), dismissAction: { }) + } +} diff --git a/ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift b/ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift new file mode 100644 index 000000000..5ae4a8b83 --- /dev/null +++ b/ElementX/Sources/Screens/SearchUsersSection/SearchUsersSection.swift @@ -0,0 +1,37 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SearchUsersSection { + let type: SearchUserSectionType + let users: [UserProfile] +} + +enum SearchUserSectionType: Equatable { + case searchResult + case suggestions + case empty + + var title: String? { + switch self { + case .searchResult, .empty: + return nil + case .suggestions: + return L10n.commonSuggestions + } + } +} diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift b/ElementX/Sources/Screens/SearchUsersSection/UserProfileCell.swift similarity index 98% rename from ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift rename to ElementX/Sources/Screens/SearchUsersSection/UserProfileCell.swift index 65719b81e..21789a6d7 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatSuggestedUserCell.swift +++ b/ElementX/Sources/Screens/SearchUsersSection/UserProfileCell.swift @@ -16,7 +16,7 @@ import SwiftUI -struct StartChatSuggestedUserCell: View { +struct UserProfileCell: View { let user: UserProfile let imageProvider: ImageProviderProtocol? diff --git a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift index dbb24a892..ac4cb2e4a 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatCoordinator.swift @@ -14,11 +14,13 @@ // limitations under the License. // +import Combine import SwiftUI struct StartChatCoordinatorParameters { let userSession: UserSessionProtocol weak var userIndicatorController: UserIndicatorControllerProtocol? + let navigationStackCoordinator: NavigationStackCoordinator? } enum StartChatCoordinatorAction { @@ -29,8 +31,12 @@ enum StartChatCoordinatorAction { final class StartChatCoordinator: CoordinatorProtocol { private let parameters: StartChatCoordinatorParameters private var viewModel: StartChatViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() - var callback: ((StartChatCoordinatorAction) -> Void)? + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } init(parameters: StartChatCoordinatorParameters) { self.parameters = parameters @@ -39,20 +45,39 @@ final class StartChatCoordinator: CoordinatorProtocol { } func start() { - viewModel.callback = { [weak self] action in + viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { case .close: - self.callback?(.close) + self.actionsSubject.send(.close) case .createRoom: - break + // before creating a room we select the users we would like to invite in that room + self.presentInviteUsersScreen() case .openRoom(let identifier): - self.callback?(.openRoom(withIdentifier: identifier)) + self.actionsSubject.send(.openRoom(withIdentifier: identifier)) } } + .store(in: &cancellables) } + // MARK: - Public + func toPresentable() -> AnyView { AnyView(StartChatScreen(context: viewModel.context)) } + + // MARK: - Private + + private func presentInviteUsersScreen() { + let inviteParameters = InviteUsersCoordinatorParameters(userSession: parameters.userSession) + let coordinator = InviteUsersCoordinator(parameters: inviteParameters) + coordinator.actions.sink { [weak self] result in + switch result { + case .close: + self?.parameters.navigationStackCoordinator?.pop() + } + } + .store(in: &cancellables) + parameters.navigationStackCoordinator?.push(coordinator) + } } diff --git a/ElementX/Sources/Screens/StartChat/StartChatModels.swift b/ElementX/Sources/Screens/StartChat/StartChatModels.swift index b2a44e72a..a56571898 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatModels.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatModels.swift @@ -24,7 +24,7 @@ enum StartChatViewModelAction { struct StartChatViewState: BindableState { var bindings = StartChatScreenViewStateBindings() - var usersSection: StartChatUsersSection = .init(type: .suggestions, users: []) + var usersSection: SearchUsersSection = .init(type: .empty, users: []) var isSearching: Bool { !bindings.searchQuery.isEmpty @@ -35,25 +35,6 @@ struct StartChatViewState: BindableState { } } -enum StartChatUserSectionType: Equatable { - case searchResult - case suggestions - - var title: String? { - switch self { - case .searchResult: - return nil - case .suggestions: - return L10n.commonSuggestions - } - } -} - -struct StartChatUsersSection { - let type: StartChatUserSectionType - let users: [UserProfile] -} - struct StartChatScreenViewStateBindings { var searchQuery = "" diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift index b9345cf26..f147dde5d 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModel.swift @@ -21,8 +21,12 @@ typealias StartChatViewModelType = StateStoreViewModel = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } - var callback: ((StartChatViewModelAction) -> Void)? weak var userIndicatorController: UserIndicatorControllerProtocol? init(userSession: UserSessionProtocol, userIndicatorController: UserIndicatorControllerProtocol?) { @@ -39,9 +43,9 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { override func process(viewAction: StartChatViewAction) { switch viewAction { case .close: - callback?(.close) + actionsSubject.send(.close) case .createRoom: - callback?(.createRoom) + actionsSubject.send(.createRoom) case .inviteFriends: break case .selectUser(let user): @@ -51,7 +55,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { switch currentDirectRoom { case .success(.some(let roomId)): self.hideLoadingIndicator() - self.callback?(.openRoom(withIdentifier: roomId)) + self.actionsSubject.send(.openRoom(withIdentifier: roomId)) case .success(nil): await self.createDirectRoom(with: user) case .failure(let failure): @@ -143,7 +147,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { private func fetchSuggestions() { guard ServiceLocator.shared.settings.startChatUserSuggestionsEnabled else { - state.usersSection = .init(type: .suggestions, users: []) + state.usersSection = .init(type: .empty, users: []) return } state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) @@ -155,7 +159,7 @@ class StartChatViewModel: StartChatViewModelType, StartChatViewModelProtocol { hideLoadingIndicator() switch result { case .success(let roomId): - callback?(.openRoom(withIdentifier: roomId)) + actionsSubject.send(.openRoom(withIdentifier: roomId)) case .failure(let failure): displayError(failure) } diff --git a/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift index 7f538d14f..0052841b4 100644 --- a/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift +++ b/ElementX/Sources/Screens/StartChat/StartChatViewModelProtocol.swift @@ -14,10 +14,10 @@ // limitations under the License. // -import Foundation +import Combine @MainActor protocol StartChatViewModelProtocol { - var callback: ((StartChatViewModelAction) -> Void)? { get set } + var actions: AnyPublisher { get } var context: StartChatViewModelType.Context { get } } diff --git a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift index 581d016d3..a03f1565c 100644 --- a/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift +++ b/ElementX/Sources/Screens/StartChat/View/StartChatScreen.swift @@ -87,8 +87,9 @@ struct StartChatScreen: View { Section { ForEach(context.viewState.usersSection.users, id: \.userID) { user in Button { context.send(viewAction: .selectUser(user)) } label: { - StartChatSuggestedUserCell(user: user, imageProvider: context.imageProvider) + UserProfileCell(user: user, imageProvider: context.imageProvider) } + .buttonStyle(FormButtonStyle()) } } header: { if let title = context.viewState.usersSection.type.title { diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 600086f5e..8cf08686b 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -33,7 +33,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { private let sidebarNavigationStackCoordinator: NavigationStackCoordinator private let detailNavigationStackCoordinator: NavigationStackCoordinator - + var callback: ((UserSessionFlowCoordinatorAction) -> Void)? init(userSession: UserSessionProtocol, @@ -277,9 +277,9 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { let userIndicatorController = UserIndicatorController(rootCoordinator: startChatNavigationStackCoordinator) - let parameters = StartChatCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController) + let parameters = StartChatCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController, navigationStackCoordinator: startChatNavigationStackCoordinator) let coordinator = StartChatCoordinator(parameters: parameters) - coordinator.callback = { [weak self] action in + coordinator.actions.sink { [weak self] action in guard let self else { return } switch action { case .close: @@ -289,6 +289,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { self.stateMachine.processEvent(.selectRoom(roomId: identifier)) } } + .store(in: &cancellables) startChatNavigationStackCoordinator.setRootCoordinator(coordinator) diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 4ee8c622a..791090c24 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -312,14 +312,14 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .startChat: let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()))) + let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .startChatWithSearchResults: let navigationStackCoordinator = NavigationStackCoordinator() let clientProxy = MockClientProxy(userID: "@mock:client.com") clientProxy.searchUsersResult = .success(.init(results: [.mockAlice], limited: true)) - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .startChatSearchingNonexistentID: @@ -327,7 +327,7 @@ class MockScreen: Identifiable { let clientProxy = MockClientProxy(userID: "@mock:client.com") clientProxy.searchUsersResult = .success(.init(results: [.mockAlice], limited: true)) clientProxy.getProfileResult = .failure(.failedGettingUserProfile) - let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) + let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()), navigationStackCoordinator: navigationStackCoordinator)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .roomMemberDetailsAccountOwner: @@ -376,6 +376,11 @@ class MockScreen: Identifiable { let coordinator = InvitesCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .inviteUsers: + let navigationStackCoordinator = NavigationStackCoordinator() + let coordinator = InviteUsersCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()))) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator } }() } diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index c6a1eaa2f..f44d74d72 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -53,6 +53,7 @@ enum UITestsScreenIdentifier: String { case startChatSearchingNonexistentID case invites case invitesNoInvites + case inviteUsers } extension UITestsScreenIdentifier: CustomStringConvertible { diff --git a/UITests/Sources/InviteUsersScreenUITests.swift b/UITests/Sources/InviteUsersScreenUITests.swift new file mode 100644 index 000000000..edecb3f45 --- /dev/null +++ b/UITests/Sources/InviteUsersScreenUITests.swift @@ -0,0 +1,31 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import ElementX +import XCTest + +class InviteUsersScreenUITests: XCTestCase { + func testLanding() { + let app = Application.launch(.inviteUsers) + app.assertScreenshot(.inviteUsers) + } + + func testSelectedUsers() { + let app = Application.launch(.inviteUsers) + app.collectionViews.firstMatch.cells.element(boundBy: 1).tap() + app.assertScreenshot(.inviteUsers, step: 1) + } +} diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png new file mode 100644 index 000000000..b3a266c78 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6296d533f3cdbe4d5f1045ea1ec6357fd96cb6a1599bd6f73f4b784203d32e46 +size 108578 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png new file mode 100644 index 000000000..4b508b7c0 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.inviteUsers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9941e41387b13e98a9f4ea200015a4df439395fa8284cb0f7786b39d1fb1109a +size 100706 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.reportContent-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.reportContent-1.png new file mode 100644 index 000000000..ecb2d81f9 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.reportContent-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06d95ccd192c2c9550e4a405b76352d8f75d86a1d72cbc42dfab15743bcac2de +size 96766 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png new file mode 100644 index 000000000..3df5d34a8 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a69459351362fdce97b948e796bd1238a29d06f017c0ffd9e04dadc647fa4bb9 +size 133013 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png new file mode 100644 index 000000000..d8f5a8fa1 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.inviteUsers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f1293e87f1cf4c7cae4e836eaebd058393a958a471fd968b74181ed2098f544c +size 118210 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.reportContent-1.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.reportContent-1.png new file mode 100644 index 000000000..adeb41a6c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.reportContent-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63627788babeceb6940989acf8e07c7244d50b4ab84c97bc74b4e19b9ee6ce47 +size 120785 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png new file mode 100644 index 000000000..9c6bacc0b --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:628dbd7758485be17263284d081ac84f6d540a4ca96548ee7033bb1ccbe85418 +size 111666 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png new file mode 100644 index 000000000..4fe379a71 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.inviteUsers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c5467d6cfc30431e4dfd24844662727d80f21f296121412017b7d7add74f9c4 +size 104488 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.reportContent-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.reportContent-1.png new file mode 100644 index 000000000..3f5b5df37 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPad-9th-generation.reportContent-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:324756300a6ca0abca19892b5ff4fea9bcd1718fb9375ed87de8e9e981a06908 +size 118557 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png new file mode 100644 index 000000000..0ebcf6e6d --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71d82d5242965b7f60bc86cef9566e33e869e14d0bf8ac57b3a0b5162c8e4423 +size 137680 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png new file mode 100644 index 000000000..c407ec062 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.inviteUsers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9cf437823d358d36db516b2338c6e65eea2f6ddb8996713b17b4760cfc3c3cfb +size 120195 diff --git a/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.reportContent-1.png b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.reportContent-1.png new file mode 100644 index 000000000..77848b368 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/pseudo-iPhone-14.reportContent-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4120dca6e5eb55094f9e301bb3fa6e538b6f9a1226f29a2ab4832886661b429 +size 163825 diff --git a/UnitTests/Sources/InviteUsersViewModelTests.swift b/UnitTests/Sources/InviteUsersViewModelTests.swift new file mode 100644 index 000000000..1b1bfc6d9 --- /dev/null +++ b/UnitTests/Sources/InviteUsersViewModelTests.swift @@ -0,0 +1,62 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import ElementX + +@MainActor +class InviteUsersScreenViewModelTests: XCTestCase { + var viewModel: InviteUsersViewModelProtocol! + var clientProxy: MockClientProxy! + + var context: InviteUsersViewModel.Context { + viewModel.context + } + + override func setUpWithError() throws { + clientProxy = .init(userID: "") + let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()) + let viewModel = InviteUsersViewModel(userSession: userSession) + viewModel.state.usersSection = .init(type: .suggestions, users: [.mockAlice, .mockBob, .mockCharlie]) + self.viewModel = viewModel + } + + func testSelectUser() { + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .tapUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.count == 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + } + + func testReselectUser() { + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .tapUser(.mockAlice)) + XCTAssertEqual(context.viewState.selectedUsers.count, 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + context.send(viewAction: .tapUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + } + + func testDeselectUser() { + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + context.send(viewAction: .tapUser(.mockAlice)) + XCTAssertEqual(context.viewState.selectedUsers.count, 1) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + context.send(viewAction: .deselectUser(.mockAlice)) + XCTAssertTrue(context.viewState.selectedUsers.isEmpty) + } +} diff --git a/UnitTests/Sources/StartChatViewModelTests.swift b/UnitTests/Sources/StartChatViewModelTests.swift index cfcc3827e..0f8766639 100644 --- a/UnitTests/Sources/StartChatViewModelTests.swift +++ b/UnitTests/Sources/StartChatViewModelTests.swift @@ -35,10 +35,10 @@ class StartChatScreenViewModelTests: XCTestCase { func testQueryShowingNoResults() async throws { await search(query: "A") - XCTAssertEqual(context.viewState.usersSection.type, .suggestions) + XCTAssertEqual(context.viewState.usersSection.type, .empty) await search(query: "AA") - XCTAssertEqual(context.viewState.usersSection.type, .suggestions) + XCTAssertEqual(context.viewState.usersSection.type, .empty) await search(query: "AAA") assertSearchResults(toBe: 0) diff --git a/changelog.d/766.feature b/changelog.d/766.feature new file mode 100644 index 000000000..450867aed --- /dev/null +++ b/changelog.d/766.feature @@ -0,0 +1 @@ +Select members before creating a room (UI for selection) \ No newline at end of file