mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Squashed commits: Add unit tests and move the state machine into the FlowCoordinator [bb686861] Replace the RoomFlowCoordinator's public interface with just `handleAppRoute` [0d9a4f8d] Remove the navigationStackCoordinator dependency from the roomScreenCoordinator [4b5fbdf2] Allow rooms to be selected from any other state [41dbd127] Move all missing coordinators to the RoomFlowCoordinator and state machines [f32431b7] The UserSessionFlowCoordinator does not need to conform to the CoordinatorProtocol [0f07e87d] Fix leaving a room dismissing the currently selected one when different [138385a2] Rewind the navigation stack when re-selecting the same room (iPad) [0727eb93] Fix presenting different room details from the side menu on iPads [faf4cc60] Fix selecting the same room multiple times [fb3391da] Move room details presentation responsibility to the RoomFlowCoordinator. Fixed invitation flows. [fa2a68d9] Rename RoomTimelineFlowCoordinator -> RoomFlowCoordinator [0c9c06b5] Start moving things away from the RoomScreenCoordinator and into the RoomTimelineFlowCoordinator [86cbbdcc] Introduce a RoomTimelineFlowCoordinator to deal with timeline related operations [9b2381be] Introduce the FlowCoordinatorProtocol
This commit is contained in:
committed by
Stefan Ceriu
parent
f2b7faa183
commit
0082aba9d4
@@ -0,0 +1,480 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftState
|
||||
|
||||
enum RoomFlowCoordinatorAction: Equatable {
|
||||
case presentedRoom(String)
|
||||
case dismissedRoom
|
||||
}
|
||||
|
||||
class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let userSession: UserSessionProtocol
|
||||
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let navigationSplitCoordinator: NavigationSplitCoordinator
|
||||
private let emojiProvider: EmojiProviderProtocol
|
||||
|
||||
private let stateMachine: StateMachine<State, Event> = .init(state: .initial)
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<RoomFlowCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<RoomFlowCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var roomProxy: RoomProxyProtocol?
|
||||
private var timelineController: RoomTimelineControllerProtocol?
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
||||
navigationStackCoordinator: NavigationStackCoordinator,
|
||||
navigationSplitCoordinator: NavigationSplitCoordinator,
|
||||
emojiProvider: EmojiProviderProtocol) {
|
||||
self.userSession = userSession
|
||||
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
self.navigationSplitCoordinator = navigationSplitCoordinator
|
||||
self.emojiProvider = emojiProvider
|
||||
|
||||
setupStateMachine()
|
||||
}
|
||||
|
||||
// MARK: - FlowCoordinatorProtocol
|
||||
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
switch appRoute {
|
||||
case .room(let roomID):
|
||||
if case .room(let identifier) = stateMachine.state,
|
||||
roomID == identifier {
|
||||
return
|
||||
}
|
||||
|
||||
stateMachine.tryEvent(.presentRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated))
|
||||
case .roomDetails(let roomID):
|
||||
stateMachine.tryEvent(.presentRoomDetails(roomID: roomID), userInfo: EventUserInfo(animated: animated))
|
||||
case .roomList:
|
||||
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
switch (event, fromState) {
|
||||
case (.presentRoom(let roomID), _):
|
||||
return .room(roomID: roomID)
|
||||
case (.dismissRoom, .room):
|
||||
return .initial
|
||||
|
||||
case (.presentRoomDetails(let roomID), .initial):
|
||||
return .roomDetails(roomID: roomID)
|
||||
case (.presentRoomDetails(let roomID), .room):
|
||||
return .roomDetails(roomID: roomID)
|
||||
case (.presentRoomDetails(let roomID), .roomDetails):
|
||||
return .roomDetails(roomID: roomID)
|
||||
case (.dismissRoomDetails, .roomDetails(let roomID)):
|
||||
return .room(roomID: roomID)
|
||||
case (.dismissRoom, .roomDetails):
|
||||
return .initial
|
||||
|
||||
case (.presentMediaViewer(let file, let title), .room(let roomID)):
|
||||
return .mediaViewer(roomID: roomID, file: file, title: title)
|
||||
case (.dismissMediaViewer, .mediaViewer(let roomID, _, _)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
case (.presentReportContent(let itemID, let senderID), .room(let roomID)):
|
||||
return .reportContent(roomID: roomID, itemID: itemID, senderID: senderID)
|
||||
case (.dismissReportContent, .reportContent(let roomID, _, _)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
case (.presentMediaUploadPicker(let source), .room(let roomID)):
|
||||
return .mediaUploadPicker(roomID: roomID, source: source)
|
||||
case (.dismissMediaUploadPicker, .mediaUploadPicker(let roomID, _)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
case (.presentMediaUploadPreview(let fileURL), .mediaUploadPicker(let roomID, _)):
|
||||
return .mediaUploadPreview(roomID: roomID, fileURL: fileURL)
|
||||
case (.presentMediaUploadPreview(let fileURL), .room(let roomID)):
|
||||
return .mediaUploadPreview(roomID: roomID, fileURL: fileURL)
|
||||
case (.dismissMediaUploadPreview, .mediaUploadPreview(let roomID, _)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
case (.presentEmojiPicker(let itemID), .room(let roomID)):
|
||||
return .emojiPicker(roomID: roomID, itemID: itemID)
|
||||
case (.dismissEmojiPicker, .emojiPicker(let roomID, _)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addAnyHandler(.any => .any) { [weak self] context in
|
||||
guard let self else { return }
|
||||
|
||||
let animated = (context.userInfo as? EventUserInfo)?.animated ?? true
|
||||
|
||||
switch (context.fromState, context.event, context.toState) {
|
||||
case (_, .presentRoom(let roomID), .room):
|
||||
presentRoom(roomID, animated: animated)
|
||||
case (.room, .dismissRoom, .initial):
|
||||
dismissRoom(animated: animated)
|
||||
|
||||
case (.initial, .presentRoomDetails, .roomDetails(let roomID)),
|
||||
(.room, .presentRoomDetails, .roomDetails(let roomID)),
|
||||
(.roomDetails, .presentRoomDetails, .roomDetails(let roomID)):
|
||||
Task {
|
||||
await self.presentRoomDetails(roomID: roomID, animated: animated)
|
||||
}
|
||||
case (.roomDetails, .dismissRoomDetails, .room):
|
||||
break
|
||||
case (.roomDetails, .dismissRoom, .initial):
|
||||
dismissRoom(animated: animated)
|
||||
|
||||
case (.room, .presentMediaViewer, .mediaViewer(_, let file, let title)):
|
||||
presentMediaViewer(file, title: title)
|
||||
case (.mediaViewer, .dismissMediaViewer, .room):
|
||||
break
|
||||
|
||||
case (.room, .presentReportContent, .reportContent(_, let itemID, let senderID)):
|
||||
presentReportContent(for: itemID, from: senderID)
|
||||
case (.reportContent, .dismissReportContent, .room):
|
||||
break
|
||||
|
||||
case (.room, .presentMediaUploadPicker, .mediaUploadPicker(_, let source)):
|
||||
presentMediaUploadPickerWithSource(source)
|
||||
case (.mediaUploadPicker, .dismissMediaUploadPicker, .room):
|
||||
break
|
||||
|
||||
case (.mediaUploadPicker, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)):
|
||||
presentMediaUploadPreviewScreen(for: fileURL)
|
||||
case (.room, .presentMediaUploadPreview, .mediaUploadPreview(_, let fileURL)):
|
||||
presentMediaUploadPreviewScreen(for: fileURL)
|
||||
case (.mediaUploadPreview, .dismissMediaUploadPreview, .room):
|
||||
break
|
||||
|
||||
case (.room, .presentEmojiPicker, .emojiPicker(_, let itemID)):
|
||||
presentEmojiPicker(for: itemID)
|
||||
case (.emojiPicker, .dismissEmojiPicker, .room):
|
||||
break
|
||||
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addAnyHandler(.any => .any) { context in
|
||||
if let event = context.event {
|
||||
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`")
|
||||
} else {
|
||||
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRoom(_ roomID: String, animated: Bool) {
|
||||
Task {
|
||||
await asyncPresentRoom(roomID, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
||||
private func asyncPresentRoom(_ roomID: String, animated: Bool) async {
|
||||
if let roomProxy, roomProxy.id == roomID {
|
||||
navigationStackCoordinator.popToRoot()
|
||||
return
|
||||
}
|
||||
|
||||
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
|
||||
MXLog.error("Invalid room identifier: \(roomID)")
|
||||
stateMachine.tryEvent(.dismissRoom)
|
||||
return
|
||||
}
|
||||
|
||||
actionsSubject.send(.presentedRoom(roomID))
|
||||
|
||||
self.roomProxy = roomProxy
|
||||
|
||||
let userId = userSession.clientProxy.userID
|
||||
|
||||
let timelineItemFactory = RoomTimelineItemFactory(userID: userId,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userId))
|
||||
|
||||
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(userId: userId,
|
||||
roomProxy: roomProxy,
|
||||
timelineItemFactory: timelineItemFactory,
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
self.timelineController = timelineController
|
||||
|
||||
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy,
|
||||
timelineController: timelineController,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
emojiProvider: emojiProvider)
|
||||
let coordinator = RoomScreenCoordinator(parameters: parameters)
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .presentRoomDetails:
|
||||
stateMachine.tryEvent(.presentRoomDetails(roomID: roomID))
|
||||
case .presentMediaViewer(let file, let title):
|
||||
stateMachine.tryEvent(.presentMediaViewer(file: file, title: title))
|
||||
case .presentReportContent(let itemID, let senderID):
|
||||
stateMachine.tryEvent(.presentReportContent(itemID: itemID, senderID: senderID))
|
||||
case .presentMediaUploadPicker(let source):
|
||||
stateMachine.tryEvent(.presentMediaUploadPicker(source: source))
|
||||
case .presentMediaUploadPreviewScreen(let url):
|
||||
stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url))
|
||||
case .presentEmojiPicker(let itemID):
|
||||
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
|
||||
// Move the state machine to no room selected if the room currently being dismissed
|
||||
// is the same as the one selected in the state machine.
|
||||
// This generally happens when popping the room screen while in a compact layout
|
||||
switch self?.stateMachine.state {
|
||||
case let .room(selectedRoomID) where selectedRoomID == roomID:
|
||||
self?.stateMachine.tryEvent(.dismissRoom)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if navigationSplitCoordinator.detailCoordinator == nil {
|
||||
navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissRoom(animated: Bool) {
|
||||
navigationStackCoordinator.popToRoot(animated: true)
|
||||
navigationSplitCoordinator.setDetailCoordinator(nil)
|
||||
roomProxy = nil
|
||||
|
||||
actionsSubject.send(.dismissedRoom)
|
||||
}
|
||||
|
||||
private func presentRoomDetails(roomID: String, animated: Bool) async {
|
||||
if roomProxy?.id != roomID {
|
||||
await asyncPresentRoom(roomID, animated: true)
|
||||
}
|
||||
|
||||
guard let roomProxy else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator,
|
||||
roomProxy: roomProxy,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy))
|
||||
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case .cancel:
|
||||
self?.navigationStackCoordinator.pop()
|
||||
case .leftRoom:
|
||||
self?.dismissRoom(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if case .roomDetails = stateMachine.state {
|
||||
stateMachine.tryEvent(.dismissRoomDetails)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaViewer(_ file: MediaFileHandleProxy, title: String?) {
|
||||
let params = FilePreviewScreenCoordinatorParameters(mediaFile: file, title: title)
|
||||
let coordinator = FilePreviewScreenCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case .cancel:
|
||||
self?.navigationStackCoordinator.pop()
|
||||
}
|
||||
}
|
||||
|
||||
navigationStackCoordinator.push(coordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissMediaViewer)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentReportContent(for itemID: String, from senderID: String) {
|
||||
guard let roomProxy else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let navigationCoordinator = NavigationStackCoordinator()
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationCoordinator)
|
||||
let parameters = ReportContentScreenCoordinatorParameters(itemID: itemID,
|
||||
senderID: senderID,
|
||||
roomProxy: roomProxy,
|
||||
userIndicatorController: userIndicatorController)
|
||||
let coordinator = ReportContentScreenCoordinator(parameters: parameters)
|
||||
coordinator.callback = { [weak self] completion in
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
|
||||
switch completion {
|
||||
case .cancel:
|
||||
break
|
||||
case .finish:
|
||||
self?.showSuccess(label: L10n.commonReportSubmitted)
|
||||
}
|
||||
}
|
||||
navigationCoordinator.setRootCoordinator(coordinator)
|
||||
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissReportContent)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaUploadPickerWithSource(_ source: MediaPickerScreenSource) {
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
|
||||
|
||||
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in
|
||||
switch action {
|
||||
case .cancel:
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
case .selectMediaAtURL(let url):
|
||||
self?.stateMachine.tryEvent(.presentMediaUploadPreview(fileURL: url))
|
||||
}
|
||||
}
|
||||
|
||||
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
|
||||
if case .mediaUploadPicker = self?.stateMachine.state {
|
||||
self?.stateMachine.tryEvent(.dismissMediaUploadPicker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaUploadPreviewScreen(for url: URL) {
|
||||
guard let roomProxy else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let stackCoordinator = NavigationStackCoordinator()
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
|
||||
|
||||
let parameters = MediaUploadPreviewScreenCoordinatorParameters(userIndicatorController: userIndicatorController,
|
||||
roomProxy: roomProxy,
|
||||
mediaUploadingPreprocessor: MediaUploadingPreprocessor(),
|
||||
title: url.lastPathComponent,
|
||||
url: url)
|
||||
|
||||
let mediaUploadPreviewScreenCoordinator = MediaUploadPreviewScreenCoordinator(parameters: parameters) { [weak self] action in
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
|
||||
stackCoordinator.setRootCoordinator(mediaUploadPreviewScreenCoordinator)
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissMediaUploadPreview)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentEmojiPicker(for itemId: String) {
|
||||
let emojiPickerNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let params = EmojiPickerScreenCoordinatorParameters(emojiProvider: emojiProvider,
|
||||
itemId: itemId)
|
||||
let coordinator = EmojiPickerScreenCoordinator(parameters: params)
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case let .emojiSelected(emoji: emoji, itemId: itemId):
|
||||
MXLog.debug("Selected \(emoji) for \(itemId)")
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
Task {
|
||||
await self?.timelineController?.sendReaction(emoji, to: itemId)
|
||||
}
|
||||
case .dismiss:
|
||||
self?.navigationStackCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
}
|
||||
|
||||
emojiPickerNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
emojiPickerNavigationStackCoordinator.presentationDetents = [.medium, .large]
|
||||
|
||||
navigationStackCoordinator.setSheetCoordinator(emojiPickerNavigationStackCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissEmojiPicker)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSuccess(label: String) {
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: label, iconName: "checkmark"))
|
||||
}
|
||||
}
|
||||
|
||||
private extension RoomFlowCoordinator {
|
||||
enum State: StateType {
|
||||
case initial
|
||||
case room(roomID: String)
|
||||
case mediaViewer(roomID: String, file: MediaFileHandleProxy, title: String?)
|
||||
case reportContent(roomID: String, itemID: String, senderID: String)
|
||||
case roomDetails(roomID: String)
|
||||
case mediaUploadPicker(roomID: String, source: MediaPickerScreenSource)
|
||||
case mediaUploadPreview(roomID: String, fileURL: URL)
|
||||
case emojiPicker(roomID: String, itemID: String)
|
||||
}
|
||||
|
||||
struct EventUserInfo {
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
enum Event: EventType {
|
||||
case presentRoom(roomID: String)
|
||||
case dismissRoom
|
||||
|
||||
case presentMediaViewer(file: MediaFileHandleProxy, title: String?)
|
||||
case dismissMediaViewer
|
||||
|
||||
case presentReportContent(itemID: String, senderID: String)
|
||||
case dismissReportContent
|
||||
|
||||
case presentRoomDetails(roomID: String)
|
||||
case dismissRoomDetails
|
||||
|
||||
case presentMediaUploadPicker(source: MediaPickerScreenSource)
|
||||
case dismissMediaUploadPicker
|
||||
|
||||
case presentMediaUploadPreview(fileURL: URL)
|
||||
case dismissMediaUploadPreview
|
||||
|
||||
case presentEmojiPicker(itemID: String)
|
||||
case dismissEmojiPicker
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
enum UserSessionFlowCoordinatorAction {
|
||||
case signOut
|
||||
case clearCache
|
||||
}
|
||||
|
||||
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let userSession: UserSessionProtocol
|
||||
private let navigationSplitCoordinator: NavigationSplitCoordinator
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
|
||||
|
||||
private let stateMachine: UserSessionFlowCoordinatorStateMachine
|
||||
private let roomFlowCoordinator: RoomFlowCoordinator
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
|
||||
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
|
||||
private let detailNavigationStackCoordinator: NavigationStackCoordinator
|
||||
|
||||
var callback: ((UserSessionFlowCoordinatorAction) -> Void)?
|
||||
|
||||
init(userSession: UserSessionProtocol,
|
||||
navigationSplitCoordinator: NavigationSplitCoordinator,
|
||||
bugReportService: BugReportServiceProtocol,
|
||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol) {
|
||||
stateMachine = UserSessionFlowCoordinatorStateMachine()
|
||||
self.userSession = userSession
|
||||
self.navigationSplitCoordinator = navigationSplitCoordinator
|
||||
self.bugReportService = bugReportService
|
||||
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
||||
|
||||
sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
|
||||
detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
|
||||
|
||||
navigationSplitCoordinator.setSidebarCoordinator(sidebarNavigationStackCoordinator)
|
||||
|
||||
roomFlowCoordinator = RoomFlowCoordinator(userSession: userSession,
|
||||
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
||||
navigationStackCoordinator: detailNavigationStackCoordinator,
|
||||
navigationSplitCoordinator: navigationSplitCoordinator,
|
||||
emojiProvider: EmojiProvider())
|
||||
|
||||
setupStateMachine()
|
||||
|
||||
roomFlowCoordinator.actions.sink { action in
|
||||
switch action {
|
||||
case .presentedRoom(let roomID):
|
||||
self.stateMachine.processEvent(.selectRoom(roomId: roomID))
|
||||
case .dismissedRoom:
|
||||
self.stateMachine.processEvent(.deselectRoom)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func start() {
|
||||
stateMachine.processEvent(.start)
|
||||
}
|
||||
|
||||
func stop() { }
|
||||
|
||||
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
|
||||
stateMachine.isDisplayingRoomScreen(withRoomId: roomId)
|
||||
}
|
||||
|
||||
// MARK: - FlowCoordinatorProtocol
|
||||
|
||||
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
|
||||
switch stateMachine.state {
|
||||
case .feedbackScreen, .sessionVerificationScreen, .settingsScreen, .startChatScreen, .invitesScreen:
|
||||
navigationSplitCoordinator.setSheetCoordinator(nil, animated: animated)
|
||||
case .roomList, .initial:
|
||||
break
|
||||
}
|
||||
|
||||
switch appRoute {
|
||||
case .room, .roomDetails, .roomList:
|
||||
roomFlowCoordinator.handleAppRoute(appRoute, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func setupStateMachine() {
|
||||
stateMachine.addTransitionHandler { [weak self] context in
|
||||
guard let self else { return }
|
||||
let animated = (context.userInfo as? UserSessionFlowCoordinatorStateMachine.EventUserInfo)?.animated ?? true
|
||||
switch (context.fromState, context.event, context.toState) {
|
||||
case (.initial, .start, .roomList):
|
||||
self.presentHomeScreen()
|
||||
|
||||
case(.roomList, .selectRoom, .roomList):
|
||||
break
|
||||
case(.roomList, .deselectRoom, .roomList):
|
||||
break
|
||||
|
||||
case (.invitesScreen, .selectRoom, .invitesScreen):
|
||||
break
|
||||
case (.invitesScreen, .deselectRoom, .invitesScreen):
|
||||
break
|
||||
|
||||
case (.roomList, .showSessionVerificationScreen, .sessionVerificationScreen):
|
||||
self.presentSessionVerification(animated: animated)
|
||||
case (.sessionVerificationScreen, .dismissedSessionVerificationScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .showSettingsScreen, .settingsScreen):
|
||||
self.presentSettingsScreen(animated: animated)
|
||||
case (.settingsScreen, .dismissedSettingsScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .feedbackScreen, .feedbackScreen):
|
||||
self.presentFeedbackScreen(animated: animated)
|
||||
case (.feedbackScreen, .dismissedFeedbackScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .showStartChatScreen, .startChatScreen):
|
||||
self.presentStartChat(animated: animated)
|
||||
case (.startChatScreen, .dismissedStartChatScreen, .roomList):
|
||||
break
|
||||
|
||||
case (.roomList, .showInvitesScreen, .invitesScreen):
|
||||
self.presentInvitesList(animated: animated)
|
||||
case (.invitesScreen, .closedInvitesScreen, .roomList):
|
||||
break
|
||||
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
stateMachine.addErrorHandler { context in
|
||||
fatalError("Failed transition with context: \(context)")
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func presentHomeScreen() {
|
||||
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
|
||||
attributedStringBuilder: AttributedStringBuilder(),
|
||||
bugReportService: bugReportService,
|
||||
navigationStackCoordinator: detailNavigationStackCoordinator)
|
||||
let coordinator = HomeScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .presentRoom(let roomID):
|
||||
self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
|
||||
case .presentRoomDetails(let roomID):
|
||||
self.roomFlowCoordinator.handleAppRoute(.roomDetails(roomID: roomID), animated: true)
|
||||
case .roomLeft(let roomID):
|
||||
if case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state,
|
||||
selectedRoomId == roomID {
|
||||
self.roomFlowCoordinator.handleAppRoute(.roomList, animated: true)
|
||||
}
|
||||
case .presentSettingsScreen:
|
||||
self.stateMachine.processEvent(.showSettingsScreen)
|
||||
case .presentFeedbackScreen:
|
||||
self.stateMachine.processEvent(.feedbackScreen)
|
||||
case .presentSessionVerificationScreen:
|
||||
self.stateMachine.processEvent(.showSessionVerificationScreen)
|
||||
case .presentStartChatScreen:
|
||||
self.stateMachine.processEvent(.showStartChatScreen)
|
||||
case .signOut:
|
||||
self.callback?(.signOut)
|
||||
case .presentInvitesScreen:
|
||||
self.stateMachine.processEvent(.showInvitesScreen)
|
||||
}
|
||||
}
|
||||
|
||||
sidebarNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
private func presentSettingsScreen(animated: Bool) {
|
||||
let settingsNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: settingsNavigationStackCoordinator)
|
||||
|
||||
let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator,
|
||||
userIndicatorController: userIndicatorController,
|
||||
userSession: userSession,
|
||||
bugReportService: bugReportService)
|
||||
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters)
|
||||
settingsScreenCoordinator.callback = { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .dismiss:
|
||||
self.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
case .logout:
|
||||
self.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
self.callback?(.signOut)
|
||||
case .clearCache:
|
||||
self.callback?(.clearCache)
|
||||
}
|
||||
}
|
||||
|
||||
settingsNavigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator, animated: animated)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(userIndicatorController) { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedSettingsScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Session verification
|
||||
|
||||
private func presentSessionVerification(animated: Bool) {
|
||||
guard let sessionVerificationController = userSession.sessionVerificationController else {
|
||||
fatalError("The sessionVerificationController should aways be valid at this point")
|
||||
}
|
||||
|
||||
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationController)
|
||||
|
||||
let coordinator = SessionVerificationScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.callback = { [weak self] in
|
||||
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(coordinator, animated: animated) { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedSessionVerificationScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Start Chat
|
||||
|
||||
private func presentStartChat(animated: Bool) {
|
||||
let startChatNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: startChatNavigationStackCoordinator)
|
||||
let userDiscoveryService = UserDiscoveryService(clientProxy: userSession.clientProxy)
|
||||
let parameters = StartChatScreenCoordinatorParameters(userSession: userSession, userIndicatorController: userIndicatorController, navigationStackCoordinator: startChatNavigationStackCoordinator, userDiscoveryService: userDiscoveryService)
|
||||
let coordinator = StartChatScreenCoordinator(parameters: parameters)
|
||||
coordinator.actions.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .close:
|
||||
self.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
case .openRoom(let roomID):
|
||||
self.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
self.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
startChatNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(userIndicatorController, animated: animated) { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedStartChatScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bug reporting
|
||||
|
||||
private func presentFeedbackScreen(animated: Bool, for image: UIImage? = nil) {
|
||||
let feedbackNavigationStackCoordinator = NavigationStackCoordinator()
|
||||
|
||||
let userIndicatorController = UserIndicatorController(rootCoordinator: feedbackNavigationStackCoordinator)
|
||||
|
||||
let parameters = BugReportScreenCoordinatorParameters(bugReportService: bugReportService,
|
||||
userID: userSession.userID,
|
||||
deviceID: userSession.deviceID,
|
||||
userIndicatorController: userIndicatorController,
|
||||
screenshot: image,
|
||||
isModallyPresented: true)
|
||||
let coordinator = BugReportScreenCoordinator(parameters: parameters)
|
||||
coordinator.completion = { [weak self] _ in
|
||||
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
|
||||
}
|
||||
|
||||
feedbackNavigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(userIndicatorController, animated: animated) { [weak self] in
|
||||
self?.stateMachine.processEvent(.dismissedFeedbackScreen)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Invites list
|
||||
|
||||
private func presentInvitesList(animated: Bool) {
|
||||
let parameters = InvitesScreenCoordinatorParameters(userSession: userSession)
|
||||
let coordinator = InvitesScreenCoordinator(parameters: parameters)
|
||||
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
switch action {
|
||||
case .openRoom(let roomID):
|
||||
self?.roomFlowCoordinator.handleAppRoute(.room(roomID: roomID), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
sidebarNavigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
|
||||
self?.stateMachine.processEvent(.closedInvitesScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// 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
|
||||
import SwiftState
|
||||
|
||||
class UserSessionFlowCoordinatorStateMachine {
|
||||
/// States the AppCoordinator can find itself in
|
||||
enum State: StateType {
|
||||
/// The initial state, used before the coordinator starts
|
||||
case initial
|
||||
|
||||
/// Showing the home screen. The `selectedRoomId` represents the timeline shown on the detail panel (if any)
|
||||
case roomList(selectedRoomId: String?)
|
||||
|
||||
/// Showing the session verification flows
|
||||
case sessionVerificationScreen(selectedRoomId: String?)
|
||||
|
||||
/// Showing the session verification flows
|
||||
case feedbackScreen(selectedRoomId: String?)
|
||||
|
||||
/// Showing the settings screen
|
||||
case settingsScreen(selectedRoomId: String?)
|
||||
|
||||
/// Showing the start chat screen
|
||||
case startChatScreen(selectedRoomId: String?)
|
||||
|
||||
/// Showing invites list screen
|
||||
case invitesScreen(selectedRoomId: String?)
|
||||
}
|
||||
|
||||
struct EventUserInfo {
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
/// Events that can be triggered on the AppCoordinator state machine
|
||||
enum Event: EventType {
|
||||
/// Start the user session flows
|
||||
case start
|
||||
|
||||
/// Request presentation for a particular room
|
||||
/// - Parameter roomId:the room identifier
|
||||
case selectRoom(roomId: String)
|
||||
/// The room screen has been dismissed
|
||||
case deselectRoom
|
||||
|
||||
/// Request presentation of the settings screen
|
||||
case showSettingsScreen
|
||||
/// The settings screen has been dismissed
|
||||
case dismissedSettingsScreen
|
||||
|
||||
/// Request presentation of the feedback screen
|
||||
case feedbackScreen
|
||||
/// The feedback screen has been dismissed
|
||||
case dismissedFeedbackScreen
|
||||
|
||||
/// Request the start of the session verification flow
|
||||
case showSessionVerificationScreen
|
||||
/// Session verification has finished
|
||||
case dismissedSessionVerificationScreen
|
||||
|
||||
/// Request the start of the start chat flow
|
||||
case showStartChatScreen
|
||||
/// Start chat has been dismissed
|
||||
case dismissedStartChatScreen
|
||||
|
||||
/// Request presentation of the invites screen
|
||||
case showInvitesScreen
|
||||
/// The invites screen has been dismissed
|
||||
case closedInvitesScreen
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
|
||||
var state: UserSessionFlowCoordinatorStateMachine.State {
|
||||
stateMachine.state
|
||||
}
|
||||
|
||||
init() {
|
||||
stateMachine = StateMachine(state: .initial)
|
||||
configure()
|
||||
}
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
private func configure() {
|
||||
stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(selectedRoomId: nil)])
|
||||
|
||||
stateMachine.addRouteMapping { event, fromState, _ in
|
||||
switch (event, fromState) {
|
||||
case (.selectRoom(let roomId), .roomList):
|
||||
return .roomList(selectedRoomId: roomId)
|
||||
case (.selectRoom(let roomId), .invitesScreen):
|
||||
return .invitesScreen(selectedRoomId: roomId)
|
||||
case (.deselectRoom, .roomList):
|
||||
return .roomList(selectedRoomId: nil)
|
||||
case (.deselectRoom, .invitesScreen):
|
||||
return .invitesScreen(selectedRoomId: nil)
|
||||
|
||||
case (.showSettingsScreen, .roomList(let selectedRoomId)):
|
||||
return .settingsScreen(selectedRoomId: selectedRoomId)
|
||||
case (.dismissedSettingsScreen, .settingsScreen(let selectedRoomId)):
|
||||
return .roomList(selectedRoomId: selectedRoomId)
|
||||
|
||||
case (.feedbackScreen, .roomList(let selectedRoomId)):
|
||||
return .feedbackScreen(selectedRoomId: selectedRoomId)
|
||||
case (.dismissedFeedbackScreen, .feedbackScreen(let selectedRoomId)):
|
||||
return .roomList(selectedRoomId: selectedRoomId)
|
||||
|
||||
case (.showSessionVerificationScreen, .roomList(let selectedRoomId)):
|
||||
return .sessionVerificationScreen(selectedRoomId: selectedRoomId)
|
||||
case (.dismissedSessionVerificationScreen, .sessionVerificationScreen(let selectedRoomId)):
|
||||
return .roomList(selectedRoomId: selectedRoomId)
|
||||
|
||||
case (.showStartChatScreen, .roomList(let selectedRoomId)):
|
||||
return .startChatScreen(selectedRoomId: selectedRoomId)
|
||||
case (.dismissedStartChatScreen, .startChatScreen(let selectedRoomId)):
|
||||
return .roomList(selectedRoomId: selectedRoomId)
|
||||
|
||||
case (.showInvitesScreen, .roomList(let selectedRoomId)):
|
||||
return .invitesScreen(selectedRoomId: selectedRoomId)
|
||||
case (.closedInvitesScreen, .invitesScreen(let selectedRoomId)):
|
||||
return .roomList(selectedRoomId: selectedRoomId)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
addTransitionHandler { context in
|
||||
if let event = context.event {
|
||||
MXLog.info("Transitioning from `\(context.fromState)` to `\(context.toState)` with event `\(event)`")
|
||||
} else {
|
||||
MXLog.info("Transitioning from \(context.fromState)` to `\(context.toState)`")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to move the state machine to another state through an event
|
||||
/// It will either invoke the `transitionHandler` or the `errorHandler` depending on its current state
|
||||
func processEvent(_ event: Event, userInfo: EventUserInfo? = nil) {
|
||||
stateMachine.tryEvent(event, userInfo: userInfo)
|
||||
}
|
||||
|
||||
/// Registers a callback for processing state machine transitions
|
||||
func addTransitionHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
|
||||
stateMachine.addAnyHandler(.any => .any, handler: handler)
|
||||
}
|
||||
|
||||
/// Registers a callback for processing state machine errors
|
||||
func addErrorHandler(_ handler: @escaping StateMachine<State, Event>.Handler) {
|
||||
stateMachine.addErrorHandler(handler: handler)
|
||||
}
|
||||
|
||||
/// Flag indicating the machine is displaying room screen with given room identifier
|
||||
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
|
||||
switch stateMachine.state {
|
||||
case .roomList(let selectedRoomId):
|
||||
return roomId == selectedRoomId
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user