diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.swift new file mode 100644 index 000000000..b33e10742 --- /dev/null +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomCoordinator.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 CreateRoomCoordinatorParameters { + let selectedUsers: [UserProfile] +} + +enum CreateRoomCoordinatorAction { + case createRoom +} + +final class CreateRoomCoordinator: CoordinatorProtocol { + private let parameters: CreateRoomCoordinatorParameters + private var viewModel: CreateRoomViewModelProtocol + private let actionsSubject: PassthroughSubject = .init() + private var cancellables: Set = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(parameters: CreateRoomCoordinatorParameters) { + self.parameters = parameters + + viewModel = CreateRoomViewModel(selectedUsers: parameters.selectedUsers) + } + + func start() { + viewModel.actions.sink { [weak self] action in + guard let self else { return } + switch action { + case .createRoom: + self.actionsSubject.send(.createRoom) + } + } + .store(in: &cancellables) + } + + func toPresentable() -> AnyView { + AnyView(CreateRoomScreen(context: viewModel.context)) + } +} diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift new file mode 100644 index 000000000..6e6f880de --- /dev/null +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomModels.swift @@ -0,0 +1,30 @@ +// +// 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 CreateRoomViewModelAction { + case createRoom +} + +struct CreateRoomViewState: BindableState { + var selectedUsers: [UserProfile] +} + +enum CreateRoomViewAction { + case createRoom + case deselectUser(UserProfile) +} diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift new file mode 100644 index 000000000..fe55ca956 --- /dev/null +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModel.swift @@ -0,0 +1,43 @@ +// +// 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 CreateRoomViewModelType = StateStoreViewModel + +class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol { + private var actionsSubject: PassthroughSubject = .init() + + var actions: AnyPublisher { + actionsSubject.eraseToAnyPublisher() + } + + init(selectedUsers: [UserProfile]) { + super.init(initialViewState: CreateRoomViewState(selectedUsers: selectedUsers)) + } + + // MARK: - Public + + override func process(viewAction: CreateRoomViewAction) { + switch viewAction { + case .createRoom: + actionsSubject.send(.createRoom) + case .deselectUser(let user): + state.selectedUsers.removeAll(where: { $0.userID == user.userID }) + } + } +} diff --git a/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModelProtocol.swift b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModelProtocol.swift new file mode 100644 index 000000000..d3aa120a4 --- /dev/null +++ b/ElementX/Sources/Screens/CreateRoom/CreateRoomViewModelProtocol.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 CreateRoomViewModelProtocol { + var actions: AnyPublisher { get } + var context: CreateRoomViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift new file mode 100644 index 000000000..38ce57dfc --- /dev/null +++ b/ElementX/Sources/Screens/CreateRoom/View/CreateRoomScreen.swift @@ -0,0 +1,76 @@ +// +// 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 CreateRoomScreen: View { + @ObservedObject var context: CreateRoomViewModel.Context + + var body: some View { + ScrollView { + mainContent + } + .scrollContentBackground(.hidden) + .background(Color.element.formBackground.ignoresSafeArea()) + .navigationTitle(L10n.actionCreateARoom) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + createButton + } + } + } + + /// The main content of the view to be shown in a scroll view. + var mainContent: some View { + selectedUsersSection + } + + @ScaledMetric private var cellWidth: CGFloat = 64 + private var selectedUsersSection: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 28) { + ForEach(context.viewState.selectedUsers, id: \.userID) { user in + InviteUsersSelectedItem(user: user, imageProvider: context.imageProvider) { + deselect(user) + } + .frame(width: cellWidth) + } + } + .padding(.horizontal, 18) + } + } + + private var createButton: some View { + Button { context.send(viewAction: .createRoom) } label: { + Text(L10n.actionCreate) + } + } + + private func deselect(_ user: UserProfile) { + context.send(viewAction: .deselectUser(user)) + } +} + +// MARK: - Previews + +struct CreateRoom_Previews: PreviewProvider { + static let viewModel = CreateRoomViewModel(selectedUsers: [.mockAlice, .mockBob, .mockCharlie]) + + static var previews: some View { + CreateRoomScreen(context: viewModel.context) + } +} diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b13fe0049..ccf11ee5c 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -411,6 +411,12 @@ class MockScreen: Identifiable { let coordinator = InviteUsersScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()), userDiscoveryService: userDiscoveryMock)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .createRoom: + let navigationStackCoordinator = NavigationStackCoordinator() + let parameters = CreateRoomCoordinatorParameters(selectedUsers: [.mockAlice, .mockBob, .mockCharlie]) + let coordinator = CreateRoomCoordinator(parameters: parameters) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator } }() } diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index 58080e6fe..6563c3e8c 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -56,6 +56,7 @@ enum UITestsScreenIdentifier: String { case invitesWithBadges case invitesNoInvites case inviteUsers + case createRoom } extension UITestsScreenIdentifier: CustomStringConvertible { diff --git a/UITests/Sources/CreateRoomScreenUITests.swift b/UITests/Sources/CreateRoomScreenUITests.swift new file mode 100644 index 000000000..7d5b49b69 --- /dev/null +++ b/UITests/Sources/CreateRoomScreenUITests.swift @@ -0,0 +1,25 @@ +// +// 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 CreateRoomScreenUITests: XCTestCase { + func testLanding() { + let app = Application.launch(.createRoom) + app.assertScreenshot(.createRoom) + } +} diff --git a/UnitTests/Sources/CreateRoomViewModelTests.swift b/UnitTests/Sources/CreateRoomViewModelTests.swift new file mode 100644 index 000000000..4adf26ba4 --- /dev/null +++ b/UnitTests/Sources/CreateRoomViewModelTests.swift @@ -0,0 +1,43 @@ +// +// 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 CreateRoomScreenViewModelTests: XCTestCase { + var viewModel: CreateRoomViewModelProtocol! + var clientProxy: MockClientProxy! + + var context: CreateRoomViewModel.Context { + viewModel.context + } + + override func setUpWithError() throws { + clientProxy = .init(userID: "") + let viewModel = CreateRoomViewModel(selectedUsers: [.mockAlice, .mockBob, .mockCharlie]) + self.viewModel = viewModel + } + + func testDeselectUser() { + XCTAssertFalse(context.viewState.selectedUsers.isEmpty) + XCTAssertEqual(context.viewState.selectedUsers.count, 3) + XCTAssertEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + context.send(viewAction: .deselectUser(.mockAlice)) + XCTAssertNotEqual(context.viewState.selectedUsers.first?.userID, UserProfile.mockAlice.userID) + } +}