SP2: Adding Rooms to Spaces element-ios#5230

- Implemented designs with new & existing tabs in a bottom sheet
- Replaced rough edge warnings from space panel overflow with working journeys
This commit is contained in:
Gil Eluard
2021-12-23 14:08:00 +01:00
parent 9bf1c994cc
commit cfd00fea40
55 changed files with 1621 additions and 613 deletions
@@ -0,0 +1,100 @@
//
// 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 UIKit
import SwiftUI
@available(iOS 14.0, *)
internal protocol MatrixItemChooserCoordinatorViewProvider {
func view(with viewModel: MatrixItemChooserViewModelType.Context) -> AnyView
}
@available(iOS 14.0, *)
struct MatrixItemChooserCoordinatorParameters {
let session: MXSession
let title: String?
let detail: String?
let selectedItemsIds: [String]
let viewProvider: MatrixItemChooserCoordinatorViewProvider?
let itemsProcessor: MatrixItemChooserProcessorProtocol?
init(session: MXSession,
title: String? = nil,
detail: String? = nil,
selectedItemsIds: [String] = [],
viewProvider: MatrixItemChooserCoordinatorViewProvider? = nil,
itemsProcessor: MatrixItemChooserProcessorProtocol?) {
self.session = session
self.title = title
self.detail = detail
self.selectedItemsIds = selectedItemsIds
self.viewProvider = viewProvider
self.itemsProcessor = itemsProcessor
}
}
@available(iOS 14.0.0, *)
final class MatrixItemChooserCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: MatrixItemChooserCoordinatorParameters
private let matrixItemChooserHostingController: UIViewController
private var matrixItemChooserViewModel: MatrixItemChooserViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((MatrixItemChooserViewModelResult) -> Void)?
// MARK: - Setup
@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)
matrixItemChooserViewModel = viewModel
if let viewProvider = parameters.viewProvider {
let view = viewProvider.view(with: viewModel.context).addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
matrixItemChooserHostingController = VectorHostingController(rootView: view)
} else {
let view = MatrixItemChooser(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
matrixItemChooserHostingController = VectorHostingController(rootView: view)
}
}
// MARK: - Coordinator
func start() {
MXLog.debug("[MatrixItemChooserCoordinator] did start.")
matrixItemChooserViewModel.completion = { [weak self] result in
MXLog.debug("[MatrixItemChooserCoordinator] MatrixItemChooserViewModel did complete with result: \(result).")
guard let self = self else { return }
self.completion?(result)
}
}
// MARK: - Presentable
func toPresentable() -> UIViewController {
return self.matrixItemChooserHostingController
}
}
@@ -0,0 +1,68 @@
//
// 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
// MARK: - Coordinator
enum MatrixItemChooserType {
case room
case people
}
// MARK: View model
enum MatrixItemChooserStateAction {
case loadingState(Bool)
case updateError(Error?)
case updateItems([MatrixListItemData])
case updateSelection(Set<String>)
}
enum MatrixItemChooserViewModelResult {
case cancel
case done([String])
case back
}
// MARK: View
struct MatrixListItemData {
let id: String
let avatar: AvatarInput
let displayName: String?
let detailText: String?
}
extension MatrixListItemData: Identifiable, Equatable {}
struct MatrixItemChooserViewState: BindableState {
var title: String?
var message: String?
var emptyListMessage: String
var items: [MatrixListItemData]
var selectedItemIds: Set<String>
var loading: Bool
var error: String?
}
enum MatrixItemChooserViewAction {
case searchTextChanged(String)
case itemTapped(_ itemId: String)
case done
case cancel
case back
}
@@ -0,0 +1,127 @@
//
// 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
import Combine
@available(iOS 14, *)
typealias MatrixItemChooserViewModelType = StateStoreViewModel<MatrixItemChooserViewState,
MatrixItemChooserStateAction,
MatrixItemChooserViewAction>
@available(iOS 14, *)
class MatrixItemChooserViewModel: MatrixItemChooserViewModelType, MatrixItemChooserViewModelProtocol {
// MARK: - Properties
// MARK: Private
private var matrixItemChooserService: MatrixItemChooserServiceProtocol
// 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)
}
private init(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) {
self.matrixItemChooserService = matrixItemChooserService
super.init(initialViewState: Self.defaultState(matrixItemChooserService: matrixItemChooserService, title: title, detail: detail))
startObservingItems()
}
private static func defaultState(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) -> MatrixItemChooserViewState {
let title = title
let message = detail
let emptyListMessage = VectorL10n.spacesNoResultFoundTitle
return MatrixItemChooserViewState(title: title, message: message, emptyListMessage: emptyListMessage, items: matrixItemChooserService.itemsSubject.value, selectedItemIds: matrixItemChooserService.selectedItemIdsSubject.value, loading: false)
}
private func startObservingItems() {
let itemsUpdatePublisher = matrixItemChooserService.itemsSubject
.map(MatrixItemChooserStateAction.updateItems)
.eraseToAnyPublisher()
dispatch(actionPublisher: itemsUpdatePublisher)
let selectionPublisher = matrixItemChooserService.selectedItemIdsSubject
.map(MatrixItemChooserStateAction.updateSelection)
.eraseToAnyPublisher()
dispatch(actionPublisher: selectionPublisher)
}
// MARK: - Public
override func process(viewAction: MatrixItemChooserViewAction) {
switch viewAction {
case .cancel:
cancel()
case .back:
back()
case .done:
dispatch(action: .loadingState(true))
matrixItemChooserService.processSelection { [weak self] result in
guard let self = self else { return }
self.dispatch(action: .loadingState(false))
switch result {
case .success:
let selectedItemsId = Array(self.matrixItemChooserService.selectedItemIdsSubject.value)
self.done(selectedItemsId: selectedItemsId)
case .failure(let error):
self.matrixItemChooserService.refresh()
self.dispatch(action: .updateError(error))
}
}
case .searchTextChanged(let searchText):
self.matrixItemChooserService.searchText = searchText
case .itemTapped(let itemId):
self.matrixItemChooserService.reverseSelectionForItem(withId: itemId)
}
}
override class func reducer(state: inout MatrixItemChooserViewState, action: MatrixItemChooserStateAction) {
switch action {
case .updateItems(let items):
state.items = items
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))
}
private func cancel() {
completion?(.cancel)
}
private func back() {
completion?(.back)
}
}
@@ -0,0 +1,26 @@
//
// 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
protocol MatrixItemChooserViewModelProtocol {
var completion: ((MatrixItemChooserViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
static func makeMatrixItemChooserViewModel(matrixItemChooserService: MatrixItemChooserServiceProtocol, title: String?, detail: String?) -> MatrixItemChooserViewModelProtocol
@available(iOS 14, *)
var context: MatrixItemChooserViewModelType.Context { get }
}
@@ -0,0 +1,57 @@
//
// 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 SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockMatrixItemChooserScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case noItems
case items
case selectedItems
/// The associated screen
var screenType: Any.Type {
MatrixItemChooserType.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockMatrixItemChooserService
switch self {
case .noItems:
service = MockMatrixItemChooserService(type: .room, items: [])
case .items:
service = MockMatrixItemChooserService()
case .selectedItems:
service = MockMatrixItemChooserService(type: .room, items: MockMatrixItemChooserService.mockItems, selectedItemIndexes: [0, 2])
}
let viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, title: "Some title", detail: "Detail text describing the current screen")
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(MatrixItemChooser(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@@ -0,0 +1,30 @@
//
// 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 Combine
@available(iOS 14.0, *)
protocol MatrixItemChooserServiceProtocol {
var type: MatrixItemChooserType { get }
var itemsSubject: CurrentValueSubject<[MatrixListItemData], Never> { get }
var selectedItemIdsSubject: CurrentValueSubject<Set<String>, Never> { get }
var searchText: String { get set }
func reverseSelectionForItem(withId itemId: String)
func processSelection(completion: @escaping (Result<Void, Error>) -> Void)
func refresh()
}
@@ -0,0 +1,176 @@
//
// 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 Combine
protocol MatrixItemChooserProcessorProtocol {
var dataType: MatrixItemChooserType { get }
func computeSelection(withIds itemsIds:[String], completion: @escaping (Result<Void, Error>) -> Void)
func isItemIncluded(_ item: (MatrixListItemData)) -> Bool
}
@available(iOS 14.0, *)
class MatrixItemChooserService: MatrixItemChooserServiceProtocol {
// MARK: - Properties
// MARK: Private
private let processingQueue = DispatchQueue(label: "org.matrix.element.MatrixItemChooserService.processingQueue")
private let completionQueue = DispatchQueue.main
private let session: MXSession
private let items: [MatrixListItemData]
private var filteredItems: [MatrixListItemData] {
didSet {
itemsSubject.send(filteredItems)
}
}
private var selectedItemIds: Set<String>
private let itemsProcessor: MatrixItemChooserProcessorProtocol?
// MARK: Public
private(set) var type: MatrixItemChooserType
private(set) var itemsSubject: CurrentValueSubject<[MatrixListItemData], Never>
private(set) var selectedItemIdsSubject: CurrentValueSubject<Set<String>, Never>
var searchText: String = "" {
didSet {
refresh()
}
}
// MARK: - Setup
init(session: MXSession, selectedItemIds: [String], itemsProcessor: MatrixItemChooserProcessorProtocol?) {
self.session = session
self.type = itemsProcessor?.dataType ?? .room
switch type {
case .people:
self.items = session.users().map { user in
MatrixListItemData(mxUser: user)
}
case .room:
self.items = session.rooms.compactMap { room in
if room.summary.roomType == .space || room.isDirect {
return nil
}
return MatrixListItemData(mxRoom: room, spaceService: session.spaceService)
}
}
self.itemsSubject = CurrentValueSubject(self.items)
self.filteredItems = []
self.selectedItemIds = Set(selectedItemIds)
self.selectedItemIdsSubject = CurrentValueSubject(self.selectedItemIds)
self.itemsProcessor = itemsProcessor
refresh()
}
// MARK: - Public
func reverseSelectionForItem(withId itemId: String) {
if selectedItemIds.contains(itemId) {
selectedItemIds.remove(itemId)
} else {
selectedItemIds.insert(itemId)
}
selectedItemIdsSubject.send(selectedItemIds)
}
func processSelection(completion: @escaping (Result<Void, Error>) -> Void) {
guard let selectionProcessor = self.itemsProcessor else {
completion(Result.success(()))
return
}
selectionProcessor.computeSelection(withIds: Array(selectedItemIds), completion: completion)
}
func refresh() {
self.processingQueue.async { [weak self] in
guard let self = self else { return }
let filteredItems = self.filter(items: self.items)
self.completionQueue.async {
self.filteredItems = filteredItems
}
}
}
// MARK: - Private
private func filter(items: [MatrixListItemData]) -> [MatrixListItemData] {
if searchText.isEmpty {
if let selectionProcessor = self.itemsProcessor {
return items.filter {
selectionProcessor.isItemIncluded($0)
}
} else {
return items
}
} else {
let lowercasedSearchText = self.searchText.lowercased()
if let selectionProcessor = self.itemsProcessor {
return items.filter {
selectionProcessor.isItemIncluded($0) && ($0.id.lowercased().contains(lowercasedSearchText) || ($0.displayName ?? "").lowercased().contains(lowercasedSearchText))
}
} else {
return items.filter {
$0.id.lowercased().contains(lowercasedSearchText) || ($0.displayName ?? "").lowercased().contains(lowercasedSearchText)
}
}
}
}
}
fileprivate extension MatrixListItemData {
init(mxUser: MXUser) {
self.init(id: mxUser.userId, avatar: mxUser.avatarData, displayName: mxUser.displayname, detailText: mxUser.userId)
}
init(mxRoom: MXRoom, spaceService: MXSpaceService) {
let parentSapceIds = mxRoom.summary.parentSpaceIds ?? Set()
let detailText: String?
if parentSapceIds.isEmpty {
detailText = nil
} else {
if let spaceName = spaceService.getSpace(withId: parentSapceIds.first ?? "")?.summary?.displayname {
let count = parentSapceIds.count - 1
switch count {
case 0:
detailText = VectorL10n.spacesCreationInSpacename(spaceName)
case 1:
detailText = VectorL10n.spacesCreationInSpacenamePlusOne(spaceName)
default:
detailText = VectorL10n.spacesCreationInSpacenamePlusMany(spaceName, "\(count)")
}
} else {
if parentSapceIds.count > 1 {
detailText = VectorL10n.spacesCreationInManySpaces("\(parentSapceIds.count)")
} else {
detailText = VectorL10n.spacesCreationInOneSpace
}
}
}
self.init(id: mxRoom.roomId, avatar: mxRoom.avatarData, displayName: mxRoom.summary.displayname, detailText: detailText)
}
}
@@ -0,0 +1,72 @@
//
// 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 Combine
@available(iOS 14.0, *)
class MockMatrixItemChooserService: MatrixItemChooserServiceProtocol {
static let mockItems = [
MatrixListItemData(id: "!aaabaa:matrix.org", avatar: MockAvatarInput.example, displayName: "Matrix Discussion", detailText: "Descripton of this room"),
MatrixListItemData(id: "!zzasds:matrix.org", avatar: MockAvatarInput.example, displayName: "Element Mobile", detailText: "Descripton of this room"),
MatrixListItemData(id: "!scthve:matrix.org", avatar: MockAvatarInput.example, displayName: "Alice Personal", detailText: "Descripton of this room")
]
var itemsSubject: CurrentValueSubject<[MatrixListItemData], Never>
var selectedItemIdsSubject: CurrentValueSubject<Set<String>, Never>
var searchText: String = ""
var type: MatrixItemChooserType = .room
var selectedItemIds: Set<String> = Set()
init(type: MatrixItemChooserType = .room, items: [MatrixListItemData] = mockItems, selectedItemIndexes: [Int] = []) {
itemsSubject = CurrentValueSubject(items)
var selectedItemIds = Set<String>()
for index in selectedItemIndexes {
if index >= items.count {
continue
}
selectedItemIds.insert(items[index].id)
}
selectedItemIdsSubject = CurrentValueSubject(selectedItemIds)
self.selectedItemIds = selectedItemIds
}
func simulateSelectionForItem(at index: Int) {
guard index < itemsSubject.value.count else {
return
}
reverseSelectionForItem(withId: itemsSubject.value[index].id)
}
func reverseSelectionForItem(withId itemId: String) {
if selectedItemIds.contains(itemId) {
selectedItemIds.remove(itemId)
} else {
selectedItemIds.insert(itemId)
}
selectedItemIdsSubject.send(selectedItemIds)
}
func processSelection(completion: @escaping (Result<Void, Error>) -> Void) {
completion(Result.success(()))
}
func refresh() {
}
}
@@ -0,0 +1,68 @@
//
// 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 XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class MatrixItemChooserUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockMatrixItemChooserScreenState.self
}
override class func createTest() -> MockScreenTest {
return MatrixItemChooserUITests(selector: #selector(verifyMatrixItemChooserScreen))
}
func verifyMatrixItemChooserScreen() throws {
guard let screenState = screenState as? MockMatrixItemChooserScreenState else { fatalError("no screen") }
switch screenState {
case .noItems:
verifyEmptyScreen()
case .items:
verifyPopulatedScreen()
case .selectedItems:
verifyPopulatedWithSelectionScreen()
}
}
func verifyEmptyScreen() {
XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle)
XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage)
XCTAssertEqual(app.collectionViews["itemsList"].exists, false)
XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, true)
XCTAssertEqual(app.staticTexts["emptyListMessage"].label, VectorL10n.spacesNoResultFoundTitle)
XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip)
}
func verifyPopulatedScreen() {
XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle)
XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage)
XCTAssertEqual(app.collectionViews["itemsList"].exists, true)
XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false)
XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip)
}
func verifyPopulatedWithSelectionScreen() {
XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle)
XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage)
XCTAssertEqual(app.collectionViews["itemsList"].exists, true)
XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false)
XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.next)
}
}
@@ -0,0 +1,50 @@
//
// 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 XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class MatrixItemChooserViewModelTests: XCTestCase {
var creationParameters = SpaceCreationParameters()
var service: MockMatrixItemChooserService!
var viewModel: MatrixItemChooserViewModelProtocol!
var context: MatrixItemChooserViewModel.Context!
override func setUpWithError() throws {
service = MockMatrixItemChooserService(type: .room)
viewModel = MatrixItemChooserViewModel.makeMatrixItemChooserViewModel(matrixItemChooserService: service, creationParams: creationParameters)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.navTitle, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle)
XCTAssertEqual(context.viewState.emptyListMessage, VectorL10n.spacesNoResultFoundTitle)
XCTAssertEqual(context.viewState.title, VectorL10n.spacesCreationAddRoomsTitle)
XCTAssertEqual(context.viewState.message, VectorL10n.spacesCreationAddRoomsMessage)
XCTAssertEqual(context.viewState.items, MockSpaceCreationMatrixItemChooserService.mockItems)
XCTAssertEqual(context.viewState.selectedItemIds.count, 0)
}
func testItemSelection() throws {
XCTAssertEqual(context.viewState.selectedItemIds.count, 0)
service.simulateSelectionForItem(at: 0)
XCTAssertEqual(context.viewState.selectedItemIds.count, 1)
XCTAssertEqual(context.viewState.selectedItemIds.first, MockSpaceCreationMatrixItemChooserService.mockItems[0].id)
}
}
@@ -0,0 +1,122 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser
//
// 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 MatrixItemChooser: View {
// MARK: Properties
@ObservedObject var viewModel: MatrixItemChooserViewModel.Context
@State var searchText: String = ""
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ViewBuilder
var body: some View {
listContent
.background(Color.clear)
.modifier(WaitOverlay(isLoading: .constant(viewModel.viewState.loading)))
.alert(isPresented: .constant(viewModel.viewState.error != nil), content: {
Alert(title: Text(MatrixKitL10n.error), message: Text(viewModel.viewState.error ?? ""), dismissButton: .cancel(Text(MatrixKitL10n.ok)))
})
}
// MARK: Private
@ViewBuilder
private var listContent: some View {
ScrollView{
headerView
if viewModel.viewState.items.isEmpty {
Text(viewModel.viewState.emptyListMessage)
.font(theme.fonts.body)
.foregroundColor(theme.colors.secondaryContent)
.accessibility(identifier: "emptyListMessage")
Spacer()
} else {
LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.items) { item in
MatrixItemChooserListRow(
avatar: item.avatar,
displayName: item.displayName,
detailText: item.detailText,
isSelected: viewModel.viewState.selectedItemIds.contains(item.id)
)
.onTapGesture {
viewModel.send(viewAction: .itemTapped(item.id))
}
}
}
.accessibility(identifier: "itemsList")
.frame(maxHeight: .infinity, alignment: .top)
.animation(nil)
}
}
}
@ViewBuilder
private var headerView: some View {
VStack {
if let title = viewModel.viewState.title {
Text(title)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
.padding(.horizontal)
.padding(.vertical, 8)
.accessibility(identifier: "titleText")
}
if let message = viewModel.viewState.message {
Text(message)
.font(theme.fonts.callout)
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.center)
.padding(.horizontal)
.accessibility(identifier: "messageText")
}
if viewModel.viewState.title != nil || viewModel.viewState.message != nil {
Spacer().frame(height: 24)
} else {
Spacer().frame(height: 8)
}
SearchBar(placeholder: VectorL10n.searchDefaultPlaceholder, text: $searchText)
.onChange(of: searchText, perform: { value in
viewModel.send(viewAction: .searchTextChanged(searchText))
})
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct MatrixItemChooser_Previews: PreviewProvider {
static let stateRenderer = MockMatrixItemChooserScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup(addNavigation: true)
.theme(.light).preferredColorScheme(.light)
stateRenderer.screenGroup(addNavigation: true)
.theme(.dark).preferredColorScheme(.dark)
}
}
@@ -0,0 +1,73 @@
//
// 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 MatrixItemChooserListRow: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
let avatar: AvatarInputProtocol
let displayName: String?
let detailText: String?
let isSelected: Bool
@ViewBuilder
var body: some View {
HStack{
AvatarImage(avatarData: avatar, size: .small)
VStack(alignment: .leading) {
Text(displayName ?? "")
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.callout)
.accessibility(identifier: "itemNameText")
if let detailText = self.detailText {
Text(detailText)
.foregroundColor(theme.colors.secondaryContent)
.font(theme.fonts.footnote)
.accessibility(identifier: "itemDetailText")
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.accent)
} else {
Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent)
}
}
.contentShape(Rectangle())
.padding(.horizontal)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct MatrixItemChooserListRow_Previews: PreviewProvider {
static var previews: some View {
TemplateRoomListRow(avatar: MockAvatarInput.example, displayName: "Alice")
.addDependency(MockAvatarService.example)
}
}