From 987eb7d5c0a03d11d81100b6f5a948b04c4909c7 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 27 Apr 2022 13:31:14 +0200 Subject: [PATCH] 4850 bring leaving space experience in line with web (#6062) * Bring leaving space experience in line with Web #4850 - Done --- .../xcshareddata/xcschemes/Riot.xcscheme | 3 +- Riot/Assets/en.lproj/Untranslated.strings | 7 ++ Riot/Generated/UntranslatedStrings.swift | 24 ++++ .../SpaceMenu/SpaceMenuListItemViewData.swift | 1 + .../Spaces/SpaceMenu/SpaceMenuPresenter.swift | 32 +++++- .../Spaces/SpaceMenu/SpaceMenuViewModel.swift | 26 +++-- .../Modules/Common/Util/RadioButton.swift | 70 ++++++++++++ .../Coordinator/LeaveSpaceViewProvider.swift | 31 ++++++ .../MatrixSDK/LeaveSpaceItemsProcessor.swift | 105 ++++++++++++++++++ .../Spaces/LeaveSpace/View/LeaveSpace.swift | 58 ++++++++++ .../MatrixItemChooserCoordinator.swift | 5 +- .../MatrixItemChooserModels.swift | 17 +-- .../MatrixItemChooserViewModel.swift | 68 ++++++------ .../MatrixItemChooserViewModelProtocol.swift | 2 +- .../MockMatrixItemChooserScreenState.swift | 11 +- .../MatrixItemChooserServiceProtocol.swift | 3 + ...xItemChooserDirectChildrenDataSource.swift | 50 +++++++++ .../MatrixSDK/MatrixItemChooserService.swift | 23 ++++ .../Mock/MockMatrixItemChooserService.swift | 23 ++++ .../Test/UI/MatrixItemChooserUITests.swift | 2 + .../MatrixItemChooserViewModelTests.swift | 2 +- .../View/MatrixItemChooser.swift | 33 +++++- changelog.d/4850.change | 1 + 23 files changed, 537 insertions(+), 60 deletions(-) create mode 100644 RiotSwiftUI/Modules/Common/Util/RadioButton.swift create mode 100644 RiotSwiftUI/Modules/Spaces/LeaveSpace/Coordinator/LeaveSpaceViewProvider.swift create mode 100644 RiotSwiftUI/Modules/Spaces/LeaveSpace/Service/MatrixSDK/LeaveSpaceItemsProcessor.swift create mode 100644 RiotSwiftUI/Modules/Spaces/LeaveSpace/View/LeaveSpace.swift create mode 100644 RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserDirectChildrenDataSource.swift create mode 100644 changelog.d/4850.change diff --git a/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme b/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme index 84ecb908a..a9bea1d96 100644 --- a/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme +++ b/Riot.xcodeproj/xcshareddata/xcschemes/Riot.xcscheme @@ -4,7 +4,8 @@ version = "1.3"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> String { + return VectorL10n.tr("Untranslated", "leave_space_and_more_rooms", p1) + } + /// Leave space and 1 room + static var leaveSpaceAndOneRoom: String { + return VectorL10n.tr("Untranslated", "leave_space_and_one_room") + } + /// Select all rooms + static var leaveSpaceSelectionAllRooms: String { + return VectorL10n.tr("Untranslated", "leave_space_selection_all_rooms") + } + /// Select no rooms + static var leaveSpaceSelectionNoRooms: String { + return VectorL10n.tr("Untranslated", "leave_space_selection_no_rooms") + } + /// SELECT ROOMS + static var leaveSpaceSelectionTitle: String { + return VectorL10n.tr("Untranslated", "leave_space_selection_title") + } /// This feature isn't available here. For now, you can do this with %@ on your computer. static func spacesFeatureNotAvailable(_ p1: String) -> String { return VectorL10n.tr("Untranslated", "spaces_feature_not_available", p1) diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift index 8954f504d..6cf4d886e 100644 --- a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuListItemViewData.swift @@ -25,6 +25,7 @@ enum SpaceMenuListItemAction { case addSpace case settings case leaveSpace + case leaveSpaceAndChooseRooms case invite } diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift index 2cc8d9dbd..8093578d5 100644 --- a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuPresenter.swift @@ -45,7 +45,9 @@ class SpaceMenuPresenter: NSObject { private weak var selectedSpace: MXSpace? private var session: MXSession! private var spaceId: String! - + private weak var spaceMenuViewController: SpaceMenuViewController? + private var leaveSpaceCoordinator: Coordinator? + // MARK: - Public func present(forSpaceWithId spaceId: String, @@ -74,6 +76,7 @@ class SpaceMenuPresenter: NSObject { private func showMenu(for spaceId: String, session: MXSession) { let menuViewController = SpaceMenuViewController.instantiate(forSpaceWithId: spaceId, matrixSession: session, viewModel: self.viewModel) self.present(menuViewController, animated: true) + self.spaceMenuViewController = menuViewController } private func present(_ viewController: SpaceMenuViewController, animated: Bool) { @@ -96,6 +99,29 @@ class SpaceMenuPresenter: NSObject { self.presentingViewController?.present(viewController, animated: animated, completion: nil) } } + + @available(iOS 14.0, *) + private func showLeaveSpace() { + let name = session.spaceService.getSpace(withId: spaceId)?.summary?.displayname ?? VectorL10n.spaceTag + + let selectionHeader = MatrixItemChooserSelectionHeader(title: VectorL10n.leaveSpaceSelectionTitle, + selectAllTitle: VectorL10n.leaveSpaceSelectionAllRooms, + selectNoneTitle: VectorL10n.leaveSpaceSelectionNoRooms) + let paramaters = MatrixItemChooserCoordinatorParameters(session: session, + title: VectorL10n.leaveSpaceTitle(name), + detail: VectorL10n.leaveSpaceMessage(name), + selectionHeader: selectionHeader, + viewProvider: LeaveSpaceViewProvider(navTitle: nil), + itemsProcessor: LeaveSpaceItemsProcessor(spaceId: spaceId, session: session)) + let coordinator = MatrixItemChooserCoordinator(parameters: paramaters) + coordinator.start() + self.leaveSpaceCoordinator = coordinator + coordinator.completion = { [weak self] result in + self?.spaceMenuViewController?.dismiss(animated: true) + self?.leaveSpaceCoordinator = nil + } + self.spaceMenuViewController?.present(coordinator.toPresentable(), animated: true) + } } // MARK: - SpaceMenuModelViewModelCoordinatorDelegate @@ -120,6 +146,10 @@ extension SpaceMenuPresenter: SpaceMenuModelViewModelCoordinatorDelegate { self.delegate?.spaceMenuPresenter(self, didCompleteWith: .settings, forSpaceWithId: self.spaceId, with: self.session) case .invite: self.delegate?.spaceMenuPresenter(self, didCompleteWith: .invite, forSpaceWithId: self.spaceId, with: self.session) + case .leaveSpaceAndChooseRooms: + if #available(iOS 14.0, *) { + self.showLeaveSpace() + } default: MXLog.error("[SpaceMenuPresenter] spaceListViewModel didSelectItem: invalid action \(action)") } diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift index 51409d1ca..fb0fbbd9b 100644 --- a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewModel.swift @@ -85,18 +85,24 @@ class SpaceMenuViewModel: SpaceMenuViewModelType { } private func leaveSpace() { - guard let room = self.session.room(withRoomId: self.spaceId), let displayName = room.summary?.displayname else { + guard #available(iOS 14, *) else { + guard let room = self.session.room(withRoomId: self.spaceId), let displayName = room.summary?.displayname else { + return + } + + var isAdmin = false + if let roomState = room.dangerousSyncState, let powerLevels = roomState.powerLevels { + let powerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId) + let roomPowerLevel = RoomPowerLevelHelper.roomPowerLevel(from: powerLevel) + isAdmin = roomPowerLevel == .admin + } + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .leaveOptions(displayName, isAdmin)) return } - - var isAdmin = false - if let roomState = room.dangerousSyncState, let powerLevels = roomState.powerLevels { - let powerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId) - let roomPowerLevel = RoomPowerLevelHelper.roomPowerLevel(from: powerLevel) - isAdmin = roomPowerLevel == .admin - } - - self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .leaveOptions(displayName, isAdmin)) + + self.viewDelegate?.spaceMenuViewModel(self, didUpdateViewState: .deselect) + self.coordinatorDelegate?.spaceMenuViewModel(self, didSelectItemWith: .leaveSpaceAndChooseRooms) } private func leaveSpaceAndKeepRooms() { diff --git a/RiotSwiftUI/Modules/Common/Util/RadioButton.swift b/RiotSwiftUI/Modules/Common/Util/RadioButton.swift new file mode 100644 index 000000000..83e21c224 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/RadioButton.swift @@ -0,0 +1,70 @@ +// +// 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 + +@available(iOS 14.0, *) +struct RadioButton: View { + + // MARK: - Properties + + var title: String + var selected: Bool + let action: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + Button(action: action, label: { + HStack { + Image(systemName: selected ? "largecircle.fill.circle" : "circle") + .renderingMode(.template) + .resizable().frame(width: 20, height: 20) + .foregroundColor(selected ? theme.colors.accent : theme.colors.tertiaryContent) + Text(title) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.primaryContent) + Spacer() + } + .padding(EdgeInsets(top: 3, leading: 3, bottom: 3, trailing: 3)) + .background(Color.clear) + }) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct RadioButton_Previews: PreviewProvider { + static var previews: some View { + Group { + buttonGroup.theme(.light) + buttonGroup.theme(.dark).preferredColorScheme(.dark) + } + .padding() + } + + static var buttonGroup: some View { + VStack { + RadioButton(title: "A title", selected: false, action: {}) + RadioButton(title: "A title", selected: true, action: {}) + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/LeaveSpace/Coordinator/LeaveSpaceViewProvider.swift b/RiotSwiftUI/Modules/Spaces/LeaveSpace/Coordinator/LeaveSpaceViewProvider.swift new file mode 100644 index 000000000..63276111c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/LeaveSpace/Coordinator/LeaveSpaceViewProvider.swift @@ -0,0 +1,31 @@ +// +// Copyright 2021 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 + +class LeaveSpaceViewProvider: MatrixItemChooserCoordinatorViewProvider { + + private let navTitle: String? + + init(navTitle: String?) { + self.navTitle = navTitle + } + + @available(iOS 14, *) + func view(with viewModel: MatrixItemChooserViewModelType.Context) -> AnyView { + return AnyView(LeaveSpace(viewModel: viewModel, navTitle: navTitle)) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/LeaveSpace/Service/MatrixSDK/LeaveSpaceItemsProcessor.swift b/RiotSwiftUI/Modules/Spaces/LeaveSpace/Service/MatrixSDK/LeaveSpaceItemsProcessor.swift new file mode 100644 index 000000000..19ad74020 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/LeaveSpace/Service/MatrixSDK/LeaveSpaceItemsProcessor.swift @@ -0,0 +1,105 @@ +// +// Copyright 2021 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 +import MatrixSDK + +class LeaveSpaceItemsProcessor: MatrixItemChooserProcessorProtocol { + + // MARK: Private + + private let spaceId: String + private let session: MXSession + + // MARK: Setup + + init(spaceId: String, session: MXSession) { + self.spaceId = spaceId + self.session = session + self.dataSource = MatrixItemChooserDirectChildrenDataSource(parentId: spaceId) + } + + // MARK: MatrixItemChooserSelectionProcessorProtocol + + private(set) var dataSource: MatrixItemChooserDataSource + + var loadingText: String? { + VectorL10n.roomAccessSettingsScreenSettingRoomAccess + } + + func computeSelection(withIds itemsIds: [String], completion: @escaping (Result) -> Void) { + guard let space = self.session.spaceService.getSpace(withId: self.spaceId) else { + return + } + + self.leaveAllRooms(from: itemsIds, at: 0) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + self.leaveSpace(space, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func isItemIncluded(_ item: (MatrixListItemData)) -> Bool { + return true + } + + // MARK: Private + + /// Leave room with room ID from `roomIds` at `index`. + /// Recurse to the next index once done. + private func leaveAllRooms(from roomIds: [String], at index: Int, completion: @escaping (Result) -> Void) { + guard index < roomIds.count else { + completion(.success(())) + return + } + + guard let room = self.session.room(withRoomId: roomIds[index]), !room.isDirect else { + self.leaveAllRooms(from: roomIds, at: index+1, completion: completion) + return + } + + MXLog.debug("[LeaveSpaceItemsProcessor] leaving room \(room.displayName ?? room.roomId)") + room.leave { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.leaveAllRooms(from: roomIds, at: index+1, completion: completion) + case .failure(let error): + MXLog.error("[LeaveSpaceItemsProcessor] failed to leave room with error: \(error)") + completion(.failure(error)) + } + } + } + + private func leaveSpace(_ space: MXSpace, completion: @escaping (Result) -> Void) { + MXLog.debug("[LeaveSpaceItemsProcessor] leaving space") + space.room?.leave(completion: { response in + switch response { + case .success: + completion(.success(())) + case .failure(let error): + MXLog.error("[LeaveSpaceItemsProcessor] failed to leave space with error: \(error)") + completion(.failure(error)) + } + }) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/LeaveSpace/View/LeaveSpace.swift b/RiotSwiftUI/Modules/Spaces/LeaveSpace/View/LeaveSpace.swift new file mode 100644 index 000000000..1cb7bce66 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/LeaveSpace/View/LeaveSpace.swift @@ -0,0 +1,58 @@ +// +// Copyright 2021 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 + +@available(iOS 14.0, *) +struct LeaveSpace: View { + + // MARK: Properties + + @ObservedObject var viewModel: MatrixItemChooserViewModel.Context + let navTitle: String? + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @ViewBuilder + var body: some View { + mainView + .background(theme.colors.background) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + ZStack(alignment: .bottom) { + MatrixItemChooser(viewModel: viewModel, listBottomPadding: 72) + footerView + } + } + + @ViewBuilder + private var footerView: some View { + Button { + viewModel.send(viewAction: .done) + } label: { + Text(viewModel.viewState.selectedItemIds.isEmpty ? VectorL10n.leaveSpaceAction : (viewModel.viewState.selectedItemIds.count == 1 ? VectorL10n.leaveSpaceAndOneRoom : VectorL10n.leaveSpaceAndMoreRooms("\(viewModel.viewState.selectedItemIds.count)"))) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: theme.colors.alert)) + .padding() + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Coordinator/MatrixItemChooserCoordinator.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Coordinator/MatrixItemChooserCoordinator.swift index d22d62587..920dc8b66 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Coordinator/MatrixItemChooserCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Coordinator/MatrixItemChooserCoordinator.swift @@ -31,11 +31,13 @@ struct MatrixItemChooserCoordinatorParameters { let selectedItemsIds: [String] let viewProvider: MatrixItemChooserCoordinatorViewProvider? let itemsProcessor: MatrixItemChooserProcessorProtocol + let selectionHeader: MatrixItemChooserSelectionHeader? init(session: MXSession, title: String? = nil, detail: String? = nil, selectedItemsIds: [String] = [], + selectionHeader: MatrixItemChooserSelectionHeader? = nil, viewProvider: MatrixItemChooserCoordinatorViewProvider? = nil, itemsProcessor: MatrixItemChooserProcessorProtocol) { self.session = session @@ -44,6 +46,7 @@ struct MatrixItemChooserCoordinatorParameters { self.selectedItemsIds = selectedItemsIds self.viewProvider = viewProvider self.itemsProcessor = itemsProcessor + self.selectionHeader = selectionHeader } } @@ -69,7 +72,7 @@ final class MatrixItemChooserCoordinator: Coordinator, Presentable { @available(iOS 14.0, *) init(parameters: MatrixItemChooserCoordinatorParameters) { self.parameters = parameters - let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserService(session: parameters.session, selectedItemIds: parameters.selectedItemsIds, itemsProcessor: parameters.itemsProcessor), title: parameters.title, detail: parameters.detail) + let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserService(session: parameters.session, selectedItemIds: parameters.selectedItemsIds, itemsProcessor: parameters.itemsProcessor), title: parameters.title, detail: parameters.detail, selectionHeader: parameters.selectionHeader) matrixItemChooserViewModel = viewModel if let viewProvider = parameters.viewProvider { let view = viewProvider.view(with: viewModel.context).addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserModels.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserModels.swift index b82422529..da1374498 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserModels.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserModels.swift @@ -27,13 +27,6 @@ enum MatrixItemChooserType { // MARK: View model -enum MatrixItemChooserStateAction { - case loadingState(Bool) - case updateError(Error?) - case updateSections([MatrixListItemSectionData]) - case updateSelection(Set) -} - enum MatrixItemChooserViewModelResult { case cancel case done([String]) @@ -77,15 +70,23 @@ struct MatrixListItemData { extension MatrixListItemData: Identifiable, Equatable {} +struct MatrixItemChooserSelectionHeader { + var title: String + var selectAllTitle: String + var selectNoneTitle: String +} + struct MatrixItemChooserViewState: BindableState { var title: String? var message: String? var emptyListMessage: String var sections: [MatrixListItemSectionData] + var itemCount: Int var selectedItemIds: Set var loadingText: String? var loading: Bool var error: String? + var selectionHeader: MatrixItemChooserSelectionHeader? } enum MatrixItemChooserViewAction { @@ -94,4 +95,6 @@ enum MatrixItemChooserViewAction { case done case cancel case back + case selectAll + case selectNone } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModel.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModel.swift index 596a246b9..e29f81d51 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModel.swift @@ -19,7 +19,7 @@ import Combine @available(iOS 14, *) typealias MatrixItemChooserViewModelType = StateStoreViewModel @available(iOS 14, *) class MatrixItemChooserViewModel: MatrixItemChooserViewModelType, MatrixItemChooserViewModelProtocol { @@ -30,40 +30,49 @@ class MatrixItemChooserViewModel: MatrixItemChooserViewModelType, MatrixItemChoo private var matrixItemChooserService: MatrixItemChooserServiceProtocol + private var isLoading: Bool = false { + didSet { + state.loading = isLoading + if isLoading { + state.error = nil + } + } + } + // MARK: Public var completion: ((MatrixItemChooserViewModelResult) -> Void)? // MARK: - Setup - static func makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) -> MatrixItemChooserViewModelProtocol { - return MatrixItemChooserViewModel(matrixItemChooserService: matrixItemChooserService, title: title, detail: detail) + static func makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?, selectionHeader: MatrixItemChooserSelectionHeader?) -> MatrixItemChooserViewModelProtocol { + return MatrixItemChooserViewModel(matrixItemChooserService: matrixItemChooserService, title: title, detail: detail, selectionHeader: selectionHeader) } - private init(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) { + private init(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?, selectionHeader: MatrixItemChooserSelectionHeader?) { self.matrixItemChooserService = matrixItemChooserService - super.init(initialViewState: Self.defaultState(matrixItemChooserService: matrixItemChooserService, title: title, detail: detail)) + super.init(initialViewState: Self.defaultState(service: matrixItemChooserService, title: title, detail: detail, selectionHeader: selectionHeader)) startObservingItems() } - private static func defaultState(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) -> MatrixItemChooserViewState { + private static func defaultState(service: MatrixItemChooserServiceProtocol, title: String?, detail: String?, selectionHeader: MatrixItemChooserSelectionHeader?) -> MatrixItemChooserViewState { let title = title let message = detail let emptyListMessage = VectorL10n.spacesNoResultFoundTitle - return MatrixItemChooserViewState(title: title, message: message, emptyListMessage: emptyListMessage, sections: matrixItemChooserService.sectionsSubject.value, selectedItemIds: matrixItemChooserService.selectedItemIdsSubject.value, loadingText: matrixItemChooserService.loadingText, loading: false) + return MatrixItemChooserViewState(title: title, message: message, emptyListMessage: emptyListMessage, sections: service.sectionsSubject.value, itemCount: service.itemCount, selectedItemIds: service.selectedItemIdsSubject.value, loadingText: service.loadingText, loading: false, selectionHeader: selectionHeader) } private func startObservingItems() { - let sectionsUpdatePublisher = matrixItemChooserService.sectionsSubject - .map(MatrixItemChooserStateAction.updateSections) - .eraseToAnyPublisher() - dispatch(actionPublisher: sectionsUpdatePublisher) - - let selectionPublisher = matrixItemChooserService.selectedItemIdsSubject - .map(MatrixItemChooserStateAction.updateSelection) - .eraseToAnyPublisher() - dispatch(actionPublisher: selectionPublisher) + matrixItemChooserService.sectionsSubject.sink { [weak self] sections in + self?.state.sections = sections + self?.state.itemCount = self?.matrixItemChooserService.itemCount ?? 0 + } + .store(in: &cancellables) + matrixItemChooserService.selectedItemIdsSubject.sink { [weak self] selectedItemIds in + self?.state.selectedItemIds = selectedItemIds + } + .store(in: &cancellables) } // MARK: - Public @@ -75,11 +84,11 @@ class MatrixItemChooserViewModel: MatrixItemChooserViewModelType, MatrixItemChoo case .back: back() case .done: - dispatch(action: .loadingState(true)) + isLoading = true matrixItemChooserService.processSelection { [weak self] result in guard let self = self else { return } - self.dispatch(action: .loadingState(false)) + self.isLoading = false switch result { case .success: @@ -87,31 +96,20 @@ class MatrixItemChooserViewModel: MatrixItemChooserViewModelType, MatrixItemChoo self.done(selectedItemsId: selectedItemsId) case .failure(let error): self.matrixItemChooserService.refresh() - self.dispatch(action: .updateError(error)) + self.state.error = error.localizedDescription } } case .searchTextChanged(let searchText): self.matrixItemChooserService.searchText = searchText case .itemTapped(let itemId): self.matrixItemChooserService.reverseSelectionForItem(withId: itemId) + case .selectAll: + self.matrixItemChooserService.selectAllItems() + case .selectNone: + self.matrixItemChooserService.deselectAllItems() } } - - override class func reducer(state: inout MatrixItemChooserViewState, action: MatrixItemChooserStateAction) { - switch action { - case .updateSections(let sections): - state.sections = sections - case .updateSelection(let selectedItemIds): - state.selectedItemIds = selectedItemIds - case .loadingState(let loading): - state.loading = loading - state.error = nil - case .updateError(let error): - state.error = error?.localizedDescription - } - UILog.debug("[MatrixItemChooserViewModel] reducer with action \(action) produced state: \(state)") - } - + private func done(selectedItemsId: [String]) { completion?(.done(selectedItemsId)) } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModelProtocol.swift index d91b21aca..d6fd3d363 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MatrixItemChooserViewModelProtocol.swift @@ -20,7 +20,7 @@ protocol MatrixItemChooserViewModelProtocol { var completion: ((MatrixItemChooserViewModelResult) -> Void)? { get set } @available(iOS 14, *) - static func makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) -> MatrixItemChooserViewModelProtocol + static func makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?, selectionHeader: MatrixItemChooserSelectionHeader?) -> MatrixItemChooserViewModelProtocol @available(iOS 14, *) var context: MatrixItemChooserViewModelType.Context { get } } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift index 29f07f534..d26ac330b 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/MockMatrixItemChooserScreenState.swift @@ -27,6 +27,7 @@ enum MockMatrixItemChooserScreenState: MockScreenState, CaseIterable { case noItems case items case selectedItems + case selectionHeader /// The associated screen var screenType: Any.Type { @@ -36,17 +37,25 @@ enum MockMatrixItemChooserScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { let service: MockMatrixItemChooserService + let selectionHeader: MatrixItemChooserSelectionHeader? switch self { case .noItems: + selectionHeader = nil service = MockMatrixItemChooserService(type: .room, sections: [MatrixListItemSectionData()]) case .items: + selectionHeader = nil service = MockMatrixItemChooserService() case .selectedItems: + selectionHeader = nil + service = MockMatrixItemChooserService(type: .room, sections: MockMatrixItemChooserService.mockSections, selectedItemIndexPaths: [IndexPath(row: 0, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 1, section: 1)]) + case .selectionHeader: + selectionHeader = MatrixItemChooserSelectionHeader(title: "Selection Title", selectAllTitle: "Select all items", selectNoneTitle: "Select no items") service = MockMatrixItemChooserService(type: .room, sections: MockMatrixItemChooserService.mockSections, selectedItemIndexPaths: [IndexPath(row: 0, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 1, section: 1)]) } let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, title: VectorL10n.spacesCreationAddRoomsTitle, - detail: VectorL10n.spacesCreationAddRoomsMessage) + detail: VectorL10n.spacesCreationAddRoomsMessage, + selectionHeader: selectionHeader) // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixItemChooserServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixItemChooserServiceProtocol.swift index d87e4116b..4aac468d3 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixItemChooserServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixItemChooserServiceProtocol.swift @@ -23,8 +23,11 @@ protocol MatrixItemChooserServiceProtocol { var selectedItemIdsSubject: CurrentValueSubject, Never> { get } var searchText: String { get set } var loadingText: String? { get } + var itemCount: Int { get } func reverseSelectionForItem(withId itemId: String) func processSelection(completion: @escaping (Result) -> Void) func refresh() + func selectAllItems() + func deselectAllItems() } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserDirectChildrenDataSource.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserDirectChildrenDataSource.swift new file mode 100644 index 000000000..c48d6abdb --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserDirectChildrenDataSource.swift @@ -0,0 +1,50 @@ +// +// 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 + +class MatrixItemChooserDirectChildrenDataSource: MatrixItemChooserDataSource { + + // MARK: - Private + + private let parentId: String + + // MARK: - Setup + + init(parentId: String) { + self.parentId = parentId + } + + // MARK: - MatrixItemChooserDataSource + + var preselectedItemIds: Set? { nil } + + func sections(with session: MXSession, completion: @escaping (Result<[MatrixListItemSectionData], Error>) -> Void) { + let space = session.spaceService.getSpace(withId: parentId) + let children: [MatrixListItemData] = space?.childRoomIds.compactMap({ roomId in + guard let room = session.room(withRoomId: roomId), !room.isDirect else { + return nil + } + + return MatrixListItemData(mxRoom: room, spaceService: session.spaceService) + }) ?? [] + completion(Result(catching: { + [ + MatrixListItemSectionData(items: children) + ] + })) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserService.swift index 61406eac9..cc74171b2 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserService.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserService.swift @@ -61,6 +61,13 @@ class MatrixItemChooserService: MatrixItemChooserServiceProtocol { var loadingText: String? { itemsProcessor.loadingText } + var itemCount: Int { + var itemCount = 0 + for section in sections { + itemCount += section.items.count + } + return itemCount + } // MARK: - Setup @@ -118,6 +125,22 @@ class MatrixItemChooserService: MatrixItemChooserServiceProtocol { } } } + + func selectAllItems() { + var newSelection: Set = Set() + for section in sections { + for item in section.items { + newSelection.insert(item.id) + } + } + self.selectedItemIds = newSelection + selectedItemIdsSubject.send(selectedItemIds) + } + + func deselectAllItems() { + self.selectedItemIds = Set() + selectedItemIdsSubject.send(selectedItemIds) + } // MARK: - Private diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/Mock/MockMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/Mock/MockMatrixItemChooserService.swift index a6def2772..18891c742 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/Mock/MockMatrixItemChooserService.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/Mock/MockMatrixItemChooserService.swift @@ -39,6 +39,13 @@ class MockMatrixItemChooserService: MatrixItemChooserServiceProtocol { var loadingText: String? { nil } + var itemCount: Int { + var itemCount = 0 + for section in sectionsSubject.value { + itemCount += section.items.count + } + return itemCount + } init(type: MatrixItemChooserType = .room, sections: [MatrixListItemSectionData] = mockSections, selectedItemIndexPaths: [IndexPath] = []) { sectionsSubject = CurrentValueSubject(sections) @@ -79,4 +86,20 @@ class MockMatrixItemChooserService: MatrixItemChooserServiceProtocol { func refresh() { } + + func selectAllItems() { + var newSelection: Set = Set() + for section in sectionsSubject.value { + for item in section.items { + newSelection.insert(item.id) + } + } + self.selectedItemIds = newSelection + selectedItemIdsSubject.send(selectedItemIds) + } + + func deselectAllItems() { + self.selectedItemIds = Set() + selectedItemIdsSubject.send(selectedItemIds) + } } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift index c5b9c9a96..6a0918694 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/UI/MatrixItemChooserUITests.swift @@ -37,6 +37,8 @@ class MatrixItemChooserUITests: MockScreenTest { verifyPopulatedScreen() case .selectedItems: verifyPopulatedWithSelectionScreen() + case .selectionHeader: + break } } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/Unit/MatrixItemChooserViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/Unit/MatrixItemChooserViewModelTests.swift index 22c2f0d86..1e256775f 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/Unit/MatrixItemChooserViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Test/Unit/MatrixItemChooserViewModelTests.swift @@ -28,7 +28,7 @@ class MatrixItemChooserViewModelTests: XCTestCase { override func setUpWithError() throws { service = MockMatrixItemChooserService(type: .room) - viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, title: VectorL10n.spacesCreationAddRoomsTitle, detail: VectorL10n.spacesCreationAddRoomsMessage) + viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, title: VectorL10n.spacesCreationAddRoomsTitle, detail: VectorL10n.spacesCreationAddRoomsMessage, selectionHeader: nil) context = viewModel.context } diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/View/MatrixItemChooser.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/View/MatrixItemChooser.swift index 16c0aac98..1c2808261 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/View/MatrixItemChooser.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/View/MatrixItemChooser.swift @@ -90,6 +90,7 @@ struct MatrixItemChooser: View { .frame(maxHeight: .infinity, alignment: .top) .animation(nil) } + .animation(nil) } @ViewBuilder @@ -116,8 +117,36 @@ struct MatrixItemChooser: View { .onChange(of: searchText) { value in viewModel.send(viewAction: .searchTextChanged(searchText)) } + if let selectionHeader = viewModel.viewState.selectionHeader, searchText.isEmpty { + Spacer().frame(height: spacerHeight) + itemSelectionHeader(with: selectionHeader) + } } } + + private func itemSelectionHeader(with selectionHeader: MatrixItemChooserSelectionHeader) -> some View { + VStack(alignment:.leading) { + HStack { + Text(selectionHeader.title) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.primaryContent) + Text("\(viewModel.viewState.itemCount)") + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.tertiaryContent) + } + HStack { + RadioButton(title: selectionHeader.selectAllTitle, selected: viewModel.viewState.itemCount > 0 && viewModel.viewState.selectedItemIds.count == viewModel.viewState.itemCount) { + viewModel.send(viewAction: .selectAll) + } + RadioButton(title: selectionHeader.selectNoneTitle, selected: viewModel.viewState.selectedItemIds.isEmpty) { + viewModel.send(viewAction: .selectNone) + } + } + } + .padding(.vertical, 4) + .padding(.horizontal) + .background(theme.colors.tile) + } } // MARK: - Previews @@ -127,9 +156,9 @@ struct MatrixItemChooser_Previews: PreviewProvider { static let stateRenderer = MockMatrixItemChooserScreenState.stateRenderer static var previews: some View { - stateRenderer.screenGroup(addNavigation: true) + stateRenderer.screenGroup(addNavigation: false) .theme(.light).preferredColorScheme(.light) - stateRenderer.screenGroup(addNavigation: true) + stateRenderer.screenGroup(addNavigation: false) .theme(.dark).preferredColorScheme(.dark) } } diff --git a/changelog.d/4850.change b/changelog.d/4850.change new file mode 100644 index 000000000..7318b0d61 --- /dev/null +++ b/changelog.d/4850.change @@ -0,0 +1 @@ +Spaces: Bring leaving space experience in line with Web \ No newline at end of file