[iOS] Create public space #143

- Initial space creation flow
This commit is contained in:
Gil Eluard
2021-11-23 09:35:32 +01:00
parent ccfe3afb7d
commit fda0568d88
145 changed files with 7280 additions and 11 deletions
@@ -0,0 +1,71 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
/*
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
final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: SpaceCreationPostProcessCoordinatorParameters
private let spaceCreationPostProcessHostingController: UIViewController
private var spaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((SpaceCreationPostProcessCoordinatorAction) -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: SpaceCreationPostProcessCoordinatorParameters) {
self.parameters = parameters
let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessService(session: parameters.session, creationParams: parameters.creationParams))
let view = SpaceCreationPostProcess(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
spaceCreationPostProcessViewModel = viewModel
let hostingController = VectorHostingController(rootView: view)
hostingController.hidesBackTitleWhenPushed = true
spaceCreationPostProcessHostingController = hostingController
}
// MARK: - Public
func start() {
MXLog.debug("[SpaceCreationPostProcessCoordinator] did start.")
spaceCreationPostProcessViewModel.completion = { [weak self] result in
MXLog.debug("[SpaceCreationPostProcessCoordinator] SpaceCreationPostProcessViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .cancel:
self.callback?(.cancel)
case .done(let spaceId):
self.callback?(.done(spaceId))
}
}
}
func toPresentable() -> UIViewController {
return self.spaceCreationPostProcessHostingController
}
}
@@ -0,0 +1,24 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
struct SpaceCreationPostProcessCoordinatorParameters {
let session: MXSession
let creationParams: SpaceCreationParameters
}
@@ -0,0 +1,22 @@
//
// 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
enum SpaceCreationPostProcessCoordinatorAction {
case done(_ spaceId: String)
case cancel
}
@@ -0,0 +1,44 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
enum SpaceCreationPostProcessPresence {
case online
case idle
case offline
}
extension SpaceCreationPostProcessPresence {
var title: String {
switch self {
case .online:
return VectorL10n.roomParticipantsOnline
case .idle:
return VectorL10n.roomParticipantsIdle
case .offline:
return VectorL10n.roomParticipantsOffline
}
}
}
extension SpaceCreationPostProcessPresence: CaseIterable { }
extension SpaceCreationPostProcessPresence: Identifiable {
var id: Self { self }
}
@@ -0,0 +1,23 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
enum SpaceCreationPostProcessStateAction {
case updateTasks([SpaceCreationPostProcessTask])
}
@@ -0,0 +1,44 @@
//
// 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
enum SpaceCreationPostProcessTaskState: CaseIterable {
static var allCases: [SpaceCreationPostProcessTaskState] = [.none, .started, .success, .failure]
case none
case started
case success
case failure
}
enum SpaceCreationPostProcessTaskType {
case createSpace
case uploadAvatar
case createRoom(_ roomName: String)
case addRooms
case inviteUsersByEmail
}
struct SpaceCreationPostProcessTask {
let type: SpaceCreationPostProcessTaskType
let title: String
var state: SpaceCreationPostProcessTaskState
var isFinished: Bool {
return state == .failure || state == .success
}
var subTasks: [SpaceCreationPostProcessTask] = []
}
@@ -0,0 +1,25 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
enum SpaceCreationPostProcessViewAction {
case cancel
case runTasks
case retry
}
@@ -0,0 +1,24 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
enum SpaceCreationPostProcessViewModelResult {
case cancel
case done(_ spaceId: String)
}
@@ -0,0 +1,25 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
struct SpaceCreationPostProcessViewState: BindableState {
var tasks: [SpaceCreationPostProcessTask]
var isFinished: Bool
var errorCount: Int
}
@@ -0,0 +1,330 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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
import MatrixSDK
@available(iOS 14.0, *)
class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
// MARK: - Properties
// MARK: Private
private let session: MXSession
private let creationParams: SpaceCreationParameters
private var tasks: [SpaceCreationPostProcessTask] = []
private var currentTaskIndex = 0
private var isRetry = false
private(set) var createdSpace: MXSpace? {
didSet {
createdSpaceId = createdSpace?.spaceId
}
}
private(set) var createdSpaceId: String?
private var createdRoomsByName: [String: MXRoom] = [:]
private var currentSubTaskIndex = 0
private var processingQueue = DispatchQueue(label: "org.matrix.sdk.MXSpace.processingQueue", attributes: .concurrent)
private lazy var stateEventBuilder: MXRoomInitialStateEventBuilder = {
return MXRoomInitialStateEventBuilder()
}()
private lazy var mediaUploader: MXMediaLoader = {
return MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0)
}()
// MARK: Public
private(set) var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never>
// MARK: - Setup
init(session: MXSession, creationParams: SpaceCreationParameters) {
self.session = session
self.creationParams = creationParams
self.tasks = Self.tasks(with: creationParams)
self.tasksSubject = CurrentValueSubject(tasks)
}
deinit {
}
// MARK: - Public
func run() {
self.isRetry = self.currentTaskIndex > 0
self.currentTaskIndex = -1
runNextTask()
}
// MARK: - Private
private static func tasks(with creationParams: SpaceCreationParameters) -> [SpaceCreationPostProcessTask] {
guard let spaceName = creationParams.name else {
MXLog.error("[SpaceCreationPostProcessService] setupTasks: space name shouldn't be nil")
return []
}
var tasks = [SpaceCreationPostProcessTask(type: .createSpace, title: VectorL10n.spacesCreationPostProcessCreatingSpaceTask(spaceName), state: .none)]
if creationParams.userSelectedAvatar != nil {
tasks.append(SpaceCreationPostProcessTask(type: .uploadAvatar, title: VectorL10n.spacesCreationPostProcessUploadingAvatar, state: .none))
}
if creationParams.addedRoomIds.isEmpty {
tasks.append(contentsOf: creationParams.newRooms.compactMap({ room in
guard !room.name.isEmpty else {
return nil
}
return SpaceCreationPostProcessTask(type: .createRoom(room.name), title: VectorL10n.spacesCreationPostProcessCreatingRoom(room.name), state: .none)
}))
} else {
let subTasks = creationParams.addedRoomIds.map { roomId in
SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none)
}
tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(creationParams.addedRoomIds.count)"), state: .none, subTasks: subTasks))
}
if creationParams.userIdInvites.isEmpty {
let emailInviteCount = creationParams.userDefinedEmailInvites.count
if emailInviteCount > 0 {
let subTasks = creationParams.userDefinedEmailInvites.map { emailAddress in
SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: emailAddress, state: .none)
}
tasks.append(SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: VectorL10n.spacesCreationPostProcessInvitingUsers("\(creationParams.userDefinedEmailInvites.count)"), state: .none, subTasks: subTasks))
}
}
return tasks
}
private func runNextTask() {
currentTaskIndex += 1
guard currentTaskIndex < tasks.count else {
return
}
let task = tasks[currentTaskIndex]
guard !task.isFinished || task.state == .failure else {
runNextTask()
return
}
// fakeTaskExecution(task: task)
// return
switch task.type {
case .createSpace:
createSpace(andUpdate: task)
case .uploadAvatar:
uploadAvatar(andUpdate: task)
case .addRooms:
addRooms(andUpdate: task)
case .createRoom(let roomName):
if let room = createdRoomsByName[roomName] {
addToSpace(room: room)
} else {
createRoom(withName: roomName, andUpdate: task)
}
case .inviteUsersByEmail:
inviteUsersByEmail(andUpdate: task)
}
}
private func createSpace(andUpdate task: SpaceCreationPostProcessTask) {
let parameters = MXSpaceCreationParameters()
parameters.name = creationParams.name
parameters.topic = creationParams.topic
parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat
parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate
if creationParams.isPublic {
var alias = creationParams.address
if let userDefinedAlias = creationParams.userDefinedAddress {
alias = userDefinedAlias
}
parameters.roomAlias = alias?.fullLocalAlias(with: session)
let guestAccessStateEvent = self.stateEventBuilder.buildGuestAccessEvent(withAccess: .canJoin)
parameters.addOrUpdateInitialStateEvent(guestAccessStateEvent)
let historyVisibilityStateEvent = self.stateEventBuilder.buildHistoryVisibilityEvent(withVisibility: .worldReadable)
parameters.addOrUpdateInitialStateEvent(historyVisibilityStateEvent)
}
parameters.inviteArray = creationParams.userIdInvites
updateCurrentTask(with: .started)
session.spaceService.createSpace(with: parameters) { [weak self] response in
guard let self = self else { return }
if response.isFailure {
self.updateCurrentTask(with: .failure)
} else {
self.updateCurrentTask(with: .success)
self.createdSpace = response.value
self.runNextTask()
}
}
}
private func uploadAvatar(andUpdate task: SpaceCreationPostProcessTask) {
self.updateCurrentTask(with: .started)
guard let avatar = creationParams.userSelectedAvatar, let spaceRoom = self.createdSpace?.room else {
self.updateCurrentTask(with: .success)
self.runNextTask()
return
}
let avatarUp = MXKTools.forceImageOrientationUp(avatar)
mediaUploader.uploadData(avatarUp?.jpegData(compressionQuality: 0.5), filename: nil, mimeType: "image/jpeg",
success: { [weak self] (urlString) in
guard let self = self else { return }
guard let urlString = urlString else { return }
guard let url = URL(string: urlString) else { return }
self.setAvatar(ofRoom: spaceRoom, withURL: url, andUpdate: task)
},
failure: { [weak self] (error) in
guard let self = self else { return }
self.updateCurrentTask(with: .failure)
self.runNextTask()
})
}
private func setAvatar(ofRoom room: MXRoom, withURL url: URL, andUpdate task: SpaceCreationPostProcessTask) {
room.setAvatar(url: url) { [weak self] (response) in
guard let self = self else { return }
self.updateCurrentTask(with: response.isSuccess ? .success: .failure)
self.runNextTask()
}
}
private func createRoom(withName roomName: String, andUpdate task: SpaceCreationPostProcessTask) {
let parameters = MXRoomCreationParameters()
parameters.name = roomName
parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate
parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat
updateCurrentTask(with: .started)
session.createRoom(parameters: parameters) { [weak self] response in
guard let self = self else { return }
guard response.isSuccess, let createdRoom = response.value else {
self.updateCurrentTask(with: .failure)
self.runNextTask()
return
}
self.createdRoomsByName[roomName] = createdRoom
self.addToSpace(room: createdRoom)
}
}
private func addToSpace(room: MXRoom) {
self.createdSpace?.addChild(roomId: room.matrixItemId, completion: { response in
self.updateCurrentTask(with: response.isFailure ? .failure : .success)
self.runNextTask()
})
}
private func addRooms(andUpdate task: SpaceCreationPostProcessTask) {
updateCurrentTask(with: .started)
currentSubTaskIndex = -1
addNextExistingRoom()
}
private func inviteUsersByEmail(andUpdate task: SpaceCreationPostProcessTask) {
updateCurrentTask(with: .started)
currentSubTaskIndex = -1
inviteNextUserByEmail()
}
private func inviteNextUserByEmail() {
guard let createdSpace = self.createdSpace, let room = createdSpace.room else {
updateCurrentTask(with: .failure)
runNextTask()
return
}
currentSubTaskIndex += 1
guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else {
let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success })
updateCurrentTask(with: isSuccess ? .success : .failure)
runNextTask()
return
}
room.invite(.email(creationParams.emailInvites[currentSubTaskIndex])) { [weak self] response in
guard let self = self else { return }
self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure
self.inviteNextUserByEmail()
}
}
private func addNextExistingRoom() {
guard let createdSpace = self.createdSpace else {
updateCurrentTask(with: .failure)
runNextTask()
return
}
currentSubTaskIndex += 1
guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else {
let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success })
updateCurrentTask(with: isSuccess ? .success : .failure)
runNextTask()
return
}
createdSpace.addChild(roomId: creationParams.addedRoomIds[currentSubTaskIndex], completion: { [weak self] response in
guard let self = self else { return }
self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure
self.addNextExistingRoom()
})
}
private func fakeTaskExecution(task: SpaceCreationPostProcessTask) {
updateCurrentTask(with: .started)
processingQueue.async {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.updateCurrentTask(with: .success)
self.runNextTask()
}
}
}
private func updateCurrentTask(with state: SpaceCreationPostProcessTaskState) {
guard currentTaskIndex < tasks.count else {
return
}
tasks[currentTaskIndex].state = state
self.tasksSubject.send(tasks)
}
}
@@ -0,0 +1,56 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 MockSpaceCreationPostProcessScreenState: MockScreenState {
static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.tasks]
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case tasks
/// The associated screen
var screenType: Any.Type {
SpaceCreationPostProcess.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockSpaceCreationPostProcessService
switch self {
case .tasks:
service = MockSpaceCreationPostProcessService()
}
let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(SpaceCreationPostProcess(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}
@@ -0,0 +1,50 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol {
var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never>
private(set) var createdSpaceId: String?
init(
tasks: [SpaceCreationPostProcessTask] = [
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none)
]
) {
self.tasksSubject = CurrentValueSubject<[SpaceCreationPostProcessTask], Never>(tasks)
}
func simulateUpdate(presence: SpaceCreationPostProcessPresence) {
self.tasksSubject.send([
SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure),
SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success),
SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started)
])
}
func run() {
}
}
@@ -0,0 +1,27 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcessServiceProtocol: AnyObject {
var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> { get }
var createdSpaceId: String? { get }
func run()
}
@@ -0,0 +1,55 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcessUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockSpaceCreationPostProcessScreenState.self
}
override class func createTest() -> MockScreenTest {
return SpaceCreationPostProcessUITests(selector: #selector(verifySpaceCreationPostProcessScreen))
}
func verifySpaceCreationPostProcessScreen() throws {
guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") }
switch screenState {
case .presence(let presence):
verifySpaceCreationPostProcessPresence(presence: presence)
case .longDisplayName(let name):
verifySpaceCreationPostProcessLongName(name: name)
}
}
func verifySpaceCreationPostProcessPresence(presence: SpaceCreationPostProcessPresence) {
let presenceText = app.staticTexts["presenceText"]
XCTAssert(presenceText.exists)
XCTAssertEqual(presenceText.label, presence.title)
}
func verifySpaceCreationPostProcessLongName(name: String) {
let displayNameText = app.staticTexts["displayNameText"]
XCTAssert(displayNameText.exists)
XCTAssertEqual(displayNameText.label, name)
}
}
@@ -0,0 +1,59 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcessViewModelTests: XCTestCase {
private enum Constants {
static let presenceInitialValue: SpaceCreationPostProcessPresence = .offline
static let displayName = "Alice"
}
var service: MockSpaceCreationPostProcessService!
var viewModel: SpaceCreationPostProcessViewModelProtocol!
var context: SpaceCreationPostProcessViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
service = MockSpaceCreationPostProcessService(displayName: Constants.displayName, presence: Constants.presenceInitialValue)
viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service)
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.displayName, Constants.displayName)
XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue)
}
func testFirstPresenceReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first()
XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue])
}
func testPresenceUpdatesReceived() throws {
let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first()
let awaitDeferred = xcAwaitDeferred(presencePublisher)
let newPresenceValue1: SpaceCreationPostProcessPresence = .online
let newPresenceValue2: SpaceCreationPostProcessPresence = .idle
service.simulateUpdate(presence: newPresenceValue1)
service.simulateUpdate(presence: newPresenceValue2)
XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2])
}
}
@@ -0,0 +1,83 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcess: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: SpaceCreationPostProcessViewModel.Context
var body: some View {
VStack {
Spacer()
VStack(spacing: 13) {
ProgressView()
.isHidden(viewModel.viewState.isFinished)
.scaleEffect(1.5, anchor: .center)
.progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent))
Text(VectorL10n.spacesCreationPostProcessCreatingSpace)
.font(theme.fonts.calloutSB)
.foregroundColor(theme.colors.secondaryContent)
}
Spacer()
VStack(alignment: .leading, spacing: 11) {
ForEach(viewModel.viewState.tasks.indices) { index in
SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state)
}
}
Spacer()
HStack {
ThemableButton(icon: nil, title: VectorL10n.done) {
viewModel.send(viewAction: .cancel)
}
ThemableButton(icon: nil, title: VectorL10n.retry) {
viewModel.send(viewAction: .retry)
}
}
.isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0)
}
.animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount)
.padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.navigationBarHidden(true)
.background(theme.colors.background)
.frame(maxHeight: .infinity)
.onAppear() {
viewModel.send(viewAction: .runTasks)
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceCreationPostProcess_Previews: PreviewProvider {
static let stateRenderer = MockSpaceCreationPostProcessScreenState.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,87 @@
//
// 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 SpaceCreationPostProcessItem: View {
// MARK: - Properties
let title: String
let state: SpaceCreationPostProcessTaskState
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
private var tintColor: Color {
switch state {
case .none:
return theme.colors.quinaryContent
case .started:
return theme.colors.primaryContent
case .success:
return theme.colors.tertiaryContent
case .failure:
return theme.colors.alert
}
}
// MARK: Public
var body: some View {
HStack {
switch state {
case .none:
Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent)
case .started:
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent)).scaleEffect(0.9, anchor: .center)
Spacer().frame(width: 6)
case .success:
Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent)
case .failure:
Image(systemName: "exclamationmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.alert)
}
Text(title)
.font(theme.fonts.callout)
.foregroundColor(state == .started ? theme.colors.primaryContent : theme.colors.tertiaryContent)
}
.opacity(state == .none ? 0.5 : 1)
.animation(.easeOut(duration: 0.2), value: state)
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceCreationPostProcessItem_Previews: PreviewProvider {
static var previews: some View {
Group {
VStack(alignment: .leading, spacing: 20) {
SpaceCreationPostProcessItem(title: "failed task", state: .failure)
SpaceCreationPostProcessItem(title: "not started", state: .none)
SpaceCreationPostProcessItem(title: "on going task ", state: .started)
SpaceCreationPostProcessItem(title: "succesful task", state: .success)
}
VStack(alignment: .leading, spacing: 20) {
SpaceCreationPostProcessItem(title: "failed task", state: .failure)
SpaceCreationPostProcessItem(title: "not started", state: .none)
SpaceCreationPostProcessItem(title: "on going task ", state: .started)
SpaceCreationPostProcessItem(title: "succesful task", state: .success)
}.theme(.dark).preferredColorScheme(.dark)
}
.padding()
}
}
@@ -0,0 +1,140 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcessViewModelType = StateStoreViewModel<SpaceCreationPostProcessViewState,
SpaceCreationPostProcessStateAction,
SpaceCreationPostProcessViewAction>
@available(iOS 14, *)
class SpaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelType, SpaceCreationPostProcessViewModelProtocol {
// MARK: - Properties
// MARK: Private
private let spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol
private var updateNotificationObserver: Any?
// MARK: Public
var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)?
// MARK: - Setup
static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol {
return SpaceCreationPostProcessViewModel(spaceCreationPostProcessService: spaceCreationPostProcessService)
}
private init(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) {
self.spaceCreationPostProcessService = spaceCreationPostProcessService
super.init(initialViewState: Self.defaultState(spaceCreationPostProcessService: spaceCreationPostProcessService))
setupTasksObserving()
}
private static func defaultState(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewState {
let tasks = spaceCreationPostProcessService.tasksSubject.value
return SpaceCreationPostProcessViewState(
tasks: tasks,
isFinished: tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }),
errorCount: tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) })
)
}
private func setupTasksObserving() {
let tasksUpdatePublisher = spaceCreationPostProcessService.tasksSubject
.map(SpaceCreationPostProcessStateAction.updateTasks)
.eraseToAnyPublisher()
dispatch(actionPublisher: tasksUpdatePublisher)
updateNotificationObserver = NotificationCenter.default.addObserver(forName: SpaceCreationPostProcessViewModel.didUpdate, object: nil, queue: OperationQueue.main) { [weak self] notification in
guard let self = self else {
return
}
guard let state = notification.userInfo?[SpaceCreationPostProcessViewModel.newStateKey] as? SpaceCreationPostProcessViewState else {
return
}
if state.isFinished && state.errorCount == 0 {
guard let spaceId = self.spaceCreationPostProcessService.createdSpaceId else {
self.cancel()
return
}
self.done(spaceId: spaceId)
}
}
}
deinit {
if let updateNotificationObserver = self.updateNotificationObserver {
NotificationCenter.default.removeObserver(updateNotificationObserver)
}
}
// MARK: - Public
override func process(viewAction: SpaceCreationPostProcessViewAction) {
switch viewAction {
case .cancel:
cancel()
case .runTasks:
runTasks()
case .retry:
runTasks()
}
}
override class func reducer(state: inout SpaceCreationPostProcessViewState, action: SpaceCreationPostProcessStateAction) {
switch action {
case .updateTasks(let tasks):
state.tasks = tasks
state.isFinished = tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished })
state.errorCount = tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) })
}
NotificationCenter.default.post(name: SpaceCreationPostProcessViewModel.didUpdate, object: nil, userInfo: [SpaceCreationPostProcessViewModel.newStateKey : state])
UILog.debug("[SpaceCreationPostProcessViewModel] reducer with action \(action) produced state: \(state)")
}
private func done(spaceId: String) {
completion?(.done(spaceId))
}
private func cancel() {
completion?(.cancel)
}
private func runTasks() {
spaceCreationPostProcessService.run()
}
}
// MARK: - MXSpaceService notification constants
@available(iOS 14, *)
extension SpaceCreationPostProcessViewModel {
/// Posted once the process is finished
public static let didUpdate = Notification.Name("SpaceCreationPostProcessViewModelDidUpdate")
public static let newStateKey = "newState"
}
@@ -0,0 +1,28 @@
// File created from SimpleUserProfileExample
// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess
//
// 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 SpaceCreationPostProcessViewModelProtocol {
var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol
@available(iOS 14, *)
var context: SpaceCreationPostProcessViewModelType.Context { get }
}