4850 bring leaving space experience in line with web (#6062)

* Bring leaving space experience in line with Web #4850

- Done
This commit is contained in:
Gil Eluard
2022-04-27 13:31:14 +02:00
committed by GitHub
parent 751e21f97b
commit 07f8d4d52e
23 changed files with 537 additions and 60 deletions
@@ -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))
@@ -27,13 +27,6 @@ enum MatrixItemChooserType {
// MARK: View model
enum MatrixItemChooserStateAction {
case loadingState(Bool)
case updateError(Error?)
case updateSections([MatrixListItemSectionData])
case updateSelection(Set<String>)
}
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<String>
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
}
@@ -19,7 +19,7 @@ import Combine
@available(iOS 14, *)
typealias MatrixItemChooserViewModelType = StateStoreViewModel<MatrixItemChooserViewState,
MatrixItemChooserStateAction,
Never,
MatrixItemChooserViewAction>
@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))
}
@@ -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 }
}
@@ -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.
@@ -23,8 +23,11 @@ protocol MatrixItemChooserServiceProtocol {
var selectedItemIdsSubject: CurrentValueSubject<Set<String>, 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, Error>) -> Void)
func refresh()
func selectAllItems()
func deselectAllItems()
}
@@ -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<String>? { 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)
]
}))
}
}
@@ -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<String> = 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
@@ -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<String> = 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)
}
}
@@ -37,6 +37,8 @@ class MatrixItemChooserUITests: MockScreenTest {
verifyPopulatedScreen()
case .selectedItems:
verifyPopulatedWithSelectionScreen()
case .selectionHeader:
break
}
}
@@ -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
}
@@ -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)
}
}