Fixes #918, #919 - Introduce a RoomTimelineFlowCoordinator and related FlowCoordinator protocol

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:
Stefan Ceriu
2023-05-18 12:45:34 +03:00
committed by Stefan Ceriu
parent f2b7faa183
commit 0082aba9d4
12 changed files with 715 additions and 334 deletions
@@ -1,413 +0,0 @@
//
// 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: CoordinatorProtocol {
private let stateMachine: UserSessionFlowCoordinatorStateMachine
private var cancellables: Set<AnyCancellable> = .init()
private let userSession: UserSessionProtocol
private let navigationSplitCoordinator: NavigationSplitCoordinator
private let bugReportService: BugReportServiceProtocol
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
private let emojiProvider: EmojiProviderProtocol = EmojiProvider()
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)
setupStateMachine()
}
func start() {
stateMachine.processEvent(.start)
}
func stop() { }
func isDisplayingRoomScreen(withRoomId roomId: String) -> Bool {
stateMachine.isDisplayingRoomScreen(withRoomId: roomId)
}
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(let roomID):
stateMachine.processEvent(.selectRoom(roomId: roomID), userInfo: .init(animated: animated))
}
}
// 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? EventUserInfo)?.animated ?? true
switch (context.fromState, context.event, context.toState) {
case (.initial, .start, .roomList):
self.presentHomeScreen()
case(.roomList(let currentRoomId), .selectRoom, .roomList(let selectedRoomId)):
guard let selectedRoomId,
selectedRoomId != currentRoomId else {
return
}
self.presentRoomWithIdentifier(selectedRoomId, animated: animated)
case(.roomList, .deselectRoom, .roomList):
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
case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId:
self.presentRoomWithIdentifier(roomId, animated: animated)
case (.invitesScreen, .deselectRoom, .invitesScreen):
break
case (.roomList(let currentRoomId), .selectRoomDetails(let roomId), .roomList) where currentRoomId == roomId:
break
case (.roomList, .selectRoomDetails(let roomId), .roomList(let selectedRoomId)) where roomId == selectedRoomId:
self.presentRoomDetails(roomIdentifier: roomId, animated: animated)
default:
fatalError("Unknown transition: \(context)")
}
}
stateMachine.addErrorHandler { context in
fatalError("Failed transition with context: \(context)")
}
}
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 roomIdentifier):
self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier))
case .presentRoomDetails(let roomIdentifier):
self.stateMachine.processEvent(.selectRoomDetails(roomId: roomIdentifier))
case .roomLeft(let roomIdentifier):
self.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
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: Rooms
private func presentRoomWithIdentifier(_ roomIdentifier: String, animated: Bool = true) {
Task { @MainActor in
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
stateMachine.processEvent(.deselectRoom)
return
}
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)
let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
emojiProvider: emojiProvider,
userDiscoveryService: UserDiscoveryService(clientProxy: userSession.clientProxy))
let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.callback = { [weak self] action in
switch action {
case .leftRoom:
self?.dismissRoom()
}
}
detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in
guard let self else { return }
// 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 .roomList(selectedRoomId) where selectedRoomId == roomIdentifier,
let .invitesScreen(selectedRoomId) where selectedRoomId == roomIdentifier:
self.stateMachine.processEvent(.deselectRoom)
self.detailNavigationStackCoordinator.setRootCoordinator(nil)
default:
break
}
}
if navigationSplitCoordinator.detailCoordinator == nil {
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
}
}
}
private func deselectRoomIfNeeded(roomIdentifier: String) {
guard
case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state,
selectedRoomId == roomIdentifier
else {
return
}
stateMachine.processEvent(.deselectRoom)
navigationSplitCoordinator.setDetailCoordinator(nil)
}
private func dismissRoom() {
detailNavigationStackCoordinator.popToRoot(animated: true)
navigationSplitCoordinator.setDetailCoordinator(nil)
}
private func presentRoomDetails(roomIdentifier: String, animated: Bool = true) {
Task {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
return
}
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
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, .leftRoom:
self?.stateMachine.processEvent(.deselectRoom)
self?.detailNavigationStackCoordinator.setRootCoordinator(nil)
}
}
detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in
self?.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
}
if navigationSplitCoordinator.detailCoordinator == nil {
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
}
}
}
// 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 identifier):
self.navigationSplitCoordinator.setSheetCoordinator(nil)
self.stateMachine.processEvent(.selectRoom(roomId: identifier))
}
}
.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?.stateMachine.processEvent(.selectRoom(roomId: roomId))
}
}
.store(in: &cancellables)
sidebarNavigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.processEvent(.closedInvitesScreen)
}
}
}
@@ -1,182 +0,0 @@
//
// 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?)
}
/// 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
/// Request presentation of the settings of a specific room
case selectRoomDetails(roomId: String)
}
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 (.deselectRoom, .roomList):
return .roomList(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)
case (.selectRoom(let roomId), .invitesScreen):
return .invitesScreen(selectedRoomId: roomId)
case (.deselectRoom, .invitesScreen):
return .invitesScreen(selectedRoomId: nil)
case (.selectRoomDetails(let roomId), .roomList):
return .roomList(selectedRoomId: roomId)
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
}
}
}
struct EventUserInfo {
let animated: Bool
}