From a049923f21508c5ac3b954c910cd004e378e1086 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 27 May 2022 13:02:36 +0300 Subject: [PATCH] #66 - Rebuild the AppCoordinator on top of a SwiftState FSM --- ElementX.xcodeproj/project.pbxproj | 29 +++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ ElementX/Sources/AppCoordinator.swift | 108 +++++++++++++----- .../Sources/AppCoordinatorStateMachine.swift | 104 +++++++++++++++++ ElementX/SupportingFiles/target.yml | 1 + UITests/SupportingFiles/target.yml | 2 + project.yml | 3 + 7 files changed, 230 insertions(+), 26 deletions(-) create mode 100644 ElementX/Sources/AppCoordinatorStateMachine.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 84cba92b6..883a752a5 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -123,6 +123,7 @@ 978BB24F2A5D31EE59EEC249 /* UserSessionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F4134FEFE4EB55759017408 /* UserSessionProtocol.swift */; }; 992F5E750F5030C4BA2D0D03 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01C4C7DB37597D7D8379511A /* Assets.xcassets */; }; 99ED42B8F8D6BFB1DBCF4C45 /* DTCoreText in Frameworks */ = {isa = PBXBuildFile; productRef = 36B7FC232711031AA2B0D188 /* DTCoreText */; }; + 9AC5F8142413862A9E3A2D98 /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 9573B94B1C86C6DF751AF3FD /* SwiftState */; }; 9B8DE1D424E37581C7D99CCC /* RoomTimelineControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CCC6DE5FA623E31BA8546 /* RoomTimelineControllerProtocol.swift */; }; 9BD3A773186291560DF92B62 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */; }; 9C45CE85325CD591DADBC4CA /* ElementXTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */; }; @@ -141,6 +142,8 @@ AB34401E4E1CAD5D2EC3072B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9760103CF316DF68698BCFE6 /* LaunchScreen.storyboard */; }; B0887A7B5AFEC88981626389 /* MXLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64839516BD56D1C81D84C5E0 /* MXLog.swift */; }; B0EDAF55877DE19B67837C22 /* TemplateSimpleScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C29670CEC77346F31EE94C /* TemplateSimpleScreenModels.swift */; }; + B245583C63F8F90357B87FAE /* SwiftState in Frameworks */ = {isa = PBXBuildFile; productRef = 3853B78FB8531B83936C5DA6 /* SwiftState */; }; + B3FDB1D9CF40777695DBBD1D /* AppCoordinatorStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A9AB74614131D6706894E0C /* AppCoordinatorStateMachine.swift */; }; B4AAB3257A83B73F53FB2689 /* StateStoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3DFE5B444F131648066F05 /* StateStoreViewModel.swift */; }; B6DF6B6FA8734B70F9BF261E /* BlurHashDecode.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */; }; B80C4FABB5529DF12436FFDA /* AppIcon.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 16DC8C5B2991724903F1FA6A /* AppIcon.pdf */; }; @@ -301,6 +304,7 @@ 56F01DD1BBD4450E18115916 /* LabelledActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelledActivityIndicatorView.swift; sourceTree = ""; }; 5773C86AF04AEF26515AD00C /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = ""; }; + 5A9AB74614131D6706894E0C /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = ""; }; 5B9D5F812E5AD6DC786DBC9B /* NavigationRouterStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterStoreProtocol.swift; sourceTree = ""; }; 5CB7F9D6FC121204D59E18DF /* Presentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presentable.swift; sourceTree = ""; }; 5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = ""; }; @@ -500,6 +504,7 @@ CB498F4E27AA0545DCEF0F6F /* Kingfisher in Frameworks */, 6832733838C57A7D3FE8FEB5 /* Introspect in Frameworks */, 2BA59D0AEFB4B82A2EC2A326 /* SwiftyBeaver in Frameworks */, + B245583C63F8F90357B87FAE /* SwiftState in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -513,6 +518,7 @@ 9D2E03DB175A6AB14589076D /* Kingfisher in Frameworks */, 6F2AB43A1EFAD8A97AF41A15 /* Introspect in Frameworks */, 93BA4A81B6D893271101F9F0 /* SwiftyBeaver in Frameworks */, + 9AC5F8142413862A9E3A2D98 /* SwiftState in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1038,6 +1044,7 @@ isa = PBXGroup; children = ( CF3EDF23226895776553F04A /* AppCoordinator.swift */, + 5A9AB74614131D6706894E0C /* AppCoordinatorStateMachine.swift */, EFFA5FD06AAAC4AF544B594E /* AppDelegate.swift */, 967873B9E11828B67F64C89A /* UITestsAppCoordinator.swift */, CCA431E6EDD71F7067B5F9E7 /* UITestsRootView.swift */, @@ -1126,6 +1133,7 @@ 50009897F60FAE7D63EF5E5B /* Kingfisher */, 04C28663564E008DB32B5972 /* Introspect */, A981A4CA233FB5C13B9CA690 /* SwiftyBeaver */, + 3853B78FB8531B83936C5DA6 /* SwiftState */, ); productName = UITests; productReference = F506C6ADB1E1DA6638078E11 /* UITests.xctest */; @@ -1170,6 +1178,7 @@ 0DD568A494247444A4B56031 /* Kingfisher */, 5986E300FC849DEAB2EE7AEB /* Introspect */, FD43A50D9B75C9D6D30F006B /* SwiftyBeaver */, + 9573B94B1C86C6DF751AF3FD /* SwiftState */, ); productName = ElementX; productReference = 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */; @@ -1278,6 +1287,7 @@ 61916C63E3F5BD900F08DA0C /* XCRemoteSwiftPackageReference "KeychainAccess" */, D283517192CAC3E2E6920765 /* XCRemoteSwiftPackageReference "Kingfisher" */, 4FCDA8D25C7415C8FB33490D /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */, + 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */, 25B4484A6A20B9F1705DEEDA /* XCRemoteSwiftPackageReference "SwiftyBeaver" */, ); projectDirPath = ""; @@ -1391,6 +1401,7 @@ 4D23C56053013437C35E511E /* ActivityIndicatorPresenterType.swift in Sources */, FC6B7436C3A5B3D0565227D5 /* ActivityIndicatorView.swift in Sources */, A636D4900E0D98ED91536482 /* AppCoordinator.swift in Sources */, + B3FDB1D9CF40777695DBBD1D /* AppCoordinatorStateMachine.swift in Sources */, 2FE4EEF780553B25A446BBFB /* AppDelegate.swift in Sources */, 90EB25D13AE6EEF034BDE9D2 /* Assets.swift in Sources */, 3ED2725734568F6B8CC87544 /* AttributedStringBuilder.swift in Sources */, @@ -2024,6 +2035,14 @@ minimumVersion = 4.2.2; }; }; + 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ReactKit/SwiftState"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.0.0; + }; + }; A24ABD6F9CEE4D0749A6173E /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git"; @@ -2071,6 +2090,11 @@ package = C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */; productName = DTCoreText; }; + 3853B78FB8531B83936C5DA6 /* SwiftState */ = { + isa = XCSwiftPackageProductDependency; + package = 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */; + productName = SwiftState; + }; 50009897F60FAE7D63EF5E5B /* Kingfisher */ = { isa = XCSwiftPackageProductDependency; package = D283517192CAC3E2E6920765 /* XCRemoteSwiftPackageReference "Kingfisher" */; @@ -2091,6 +2115,11 @@ package = 61916C63E3F5BD900F08DA0C /* XCRemoteSwiftPackageReference "KeychainAccess" */; productName = KeychainAccess; }; + 9573B94B1C86C6DF751AF3FD /* SwiftState */ = { + isa = XCSwiftPackageProductDependency; + package = 6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */; + productName = SwiftState; + }; A678E40E917620059695F067 /* MatrixRustSDK */ = { isa = XCSwiftPackageProductDependency; package = 4FCDA8D25C7415C8FB33490D /* XCRemoteSwiftPackageReference "matrix-rust-components-swift" */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 41132ccb5..b13152db0 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -45,6 +45,15 @@ "revision" : "43c88a4b0912a1589c2a28cc9bb2df45c70cdcad" } }, + { + "identity" : "swiftstate", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ReactKit/SwiftState", + "state" : { + "revision" : "887e75b96da6be36a062e1b0ef832c32a803348b", + "version" : "6.0.1" + } + }, { "identity" : "swiftui-introspect", "kind" : "remoteSourceControl", diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index 97abe7afe..ca51ee4b4 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -12,6 +12,8 @@ import Kingfisher class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private let window: UIWindow + private var stateMachine: AppCoordinatorStateMachine + private let mainNavigationController: UINavigationController private let splashViewController: UIViewController @@ -31,6 +33,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { var childCoordinators: [Coordinator] = [] init() { + stateMachine = AppCoordinatorStateMachine() + splashViewController = SplashViewController() mainNavigationController = UINavigationController(rootViewController: splashViewController) window = UIWindow(frame: UIScreen.main.bounds) @@ -51,6 +55,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { navigationRouter: navigationRouter) authenticationCoordinator.delegate = self + setupStateMachine() + let loggerConfiguration = MXLogConfiguration() loggerConfiguration.logLevel = .verbose MXLog.configure(loggerConfiguration) @@ -59,22 +65,70 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { } func start() { - window.makeKeyAndVisible() - authenticationCoordinator.start() + stateMachine.processEvent(.start) } // MARK: - AuthenticationCoordinatorDelegate func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) { - showLoadingIndicator() + stateMachine.processEvent(.attemptedSignIn) } func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) { - hideLoadingIndicator() - showLoginErrorToast() + stateMachine.processEvent(.failedSigningIn) } func authenticationCoordinatorDidSetupClientProxy(_ authenticationCoordinator: AuthenticationCoordinator) { + stateMachine.processEvent(.succeededSigningIn) + } + + func authenticationCoordinatorDidTearDownClientProxy(_ authenticationCoordinator: AuthenticationCoordinator) { + stateMachine.processEvent(.succeededSigningOut) + } + + // MARK: - Private + + // swiftlint:disable cyclomatic_complexity + private func setupStateMachine() { + stateMachine.addTransitionHandler { [weak self] context in + guard let self = self else { return } + + switch (context.fromState, context.event, context.toState) { + case (.initial, .start, .signedOut): + self.window.makeKeyAndVisible() + self.authenticationCoordinator.start() + case (.signedOut, .attemptedSignIn, .signingIn): + self.showLoadingIndicator() + case (.signingIn, .failedSigningIn, .signedOut): + self.hideLoadingIndicator() + self.showLoginErrorToast() + case (.signingIn, .succeededSigningIn, .signedIn): + self.hideLoadingIndicator() + self.setupUserSession() + case (.signedIn, .showHomeScreen, .homeScreen): + self.presentHomeScreen() + case(_, _, .roomScreen(let roomId)): + self.presentRoomWithIdentifier(roomId) + case(.roomScreen, .dismissedRoomScreen, .homeScreen): + self.tearDownDismissedRoomScreen() + case (_, .attemptSignOut, .signingOut): + self.authenticationCoordinator.logout() + case (.signingOut, .succeededSigningOut, .signedOut): + self.tearDownUserSession() + case (.signingOut, .failedSigningOut, _): + self.showLogoutErrorToast() + default: + fatalError("Unknown transition: \(context)") + } + } + + stateMachine.addErrorHandler { context in + fatalError("Failed transition with context: \(context)") + } + } + // swiftlint:enable cyclomatic_complexity + + private func setupUserSession() { guard let clientProxy = authenticationCoordinator.clientProxy else { fatalError("User session should be setup at this point") } @@ -82,39 +136,34 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { userSession = .init(clientProxy: clientProxy, mediaProvider: MediaProvider(clientProxy: clientProxy, imageCache: ImageCache.default)) - presentHomeScreen() + stateMachine.processEvent(.showHomeScreen) } - func authenticationCoordinatorDidTearDownClientProxy(_ authenticationCoordinator: AuthenticationCoordinator) { + private func tearDownUserSession() { if let presentedCoordinator = childCoordinators.first { remove(childCoordinator: presentedCoordinator) } + userSession = nil + mainNavigationController.setViewControllers([splashViewController], animated: false) authenticationCoordinator.start() } - // MARK: - Private - private func presentHomeScreen() { - - hideLoadingIndicator() - - guard let userSession = userSession else { - fatalError("User session should be already setup at this point") - } - let parameters = HomeScreenCoordinatorParameters(userSession: userSession, attributedStringBuilder: AttributedStringBuilder(), memberDetailProviderManager: memberDetailProviderManager) let coordinator = HomeScreenCoordinator(parameters: parameters) coordinator.callback = { [weak self] action in + guard let self = self else { return } + switch action { case .logout: - self?.authenticationCoordinator.logout() + self.stateMachine.processEvent(.attemptSignOut) case .selectRoom(let roomIdentifier): - self?.presentRoomWithIdentifier(roomIdentifier) + self.stateMachine.processEvent(.showRoomScreen(roomId: roomIdentifier)) } } @@ -123,10 +172,6 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { } private func presentRoomWithIdentifier(_ roomIdentifier: String) { - guard let userSession = userSession else { - fatalError("User session should be already setup at this point") - } - guard let roomProxy = userSession.clientProxy.rooms.first(where: { $0.id == roomIdentifier }) else { MXLog.error("Invalid room identifier: \(roomIdentifier)") return @@ -147,14 +192,21 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { roomName: roomProxy.displayName ?? roomProxy.name) let coordinator = RoomScreenCoordinator(parameters: parameters) - self.add(childCoordinator: coordinator) - self.navigationRouter.push(coordinator) { [weak self] in + add(childCoordinator: coordinator) + navigationRouter.push(coordinator) { [weak self] in guard let self = self else { return } - - self.remove(childCoordinator: coordinator) + self.stateMachine.processEvent(.dismissedRoomScreen) } } + private func tearDownDismissedRoomScreen() { + guard let coordinator = childCoordinators.last as? RoomScreenCoordinator else { + fatalError("Invalid coordinator hierarchy: \(childCoordinators)") + } + + remove(childCoordinator: coordinator) + } + private func showLoadingIndicator() { loadingIndicator = indicatorPresenter.present(.loading(label: "Loading", isInteractionBlocking: true)) } @@ -166,4 +218,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { private func showLoginErrorToast() { errorIndicator = indicatorPresenter.present(.success(label: "Failed logging in")) } + + private func showLogoutErrorToast() { + errorIndicator = indicatorPresenter.present(.success(label: "Failed logging out")) + } } diff --git a/ElementX/Sources/AppCoordinatorStateMachine.swift b/ElementX/Sources/AppCoordinatorStateMachine.swift new file mode 100644 index 000000000..1e2050d46 --- /dev/null +++ b/ElementX/Sources/AppCoordinatorStateMachine.swift @@ -0,0 +1,104 @@ +// +// AppCoordinatorStateMachine.swift +// ElementX +// +// Created by Stefan Ceriu on 30/05/2022. +// Copyright © 2022 element.io. All rights reserved. +// + +import Foundation +import SwiftState + +class AppCoordinatorStateMachine { + /// States the AppCoordinator can find itself in + enum State: StateType { + /// The initial state, used before the AppCoordinator starts + case initial + /// Showing the login screen + case signedOut + /// Processing sign in request + case signingIn + /// Successfully signed in + case signedIn + /// Showing the home screen + case homeScreen + /// Showing a particular room's timeline + /// - Parameter roomId: that room's identifier + case roomScreen(roomId: String) + /// Processing a sign out request + case signingOut + } + + /// Events that can be triggered on the AppCoordinator state machine + enum Event: EventType { + /// Start AppCoordinator flows, move from initial + case start + /// A sign in request has been started + case attemptedSignIn + /// Signing it succeeded + case succeededSigningIn + /// Signing in failed + case failedSigningIn + /// Request home screen presentation + case showHomeScreen + /// Request sign out + case attemptSignOut + /// Signing out succeeded + case succeededSigningOut + /// Signing out failed + case failedSigningOut + /// Request presentation for a particular room + /// - Parameter roomId:the room identifier + case showRoomScreen(roomId: String) + /// The room screen has been dismissed + case dismissedRoomScreen + } + + private let stateMachine: StateMachine + + init() { + stateMachine = StateMachine(state: .initial) { machine in + machine.addRoutes(event: .start, transitions: [ .initial => .signedOut ]) + + machine.addRoutes(event: .attemptedSignIn, transitions: [ .signedOut => .signingIn ]) + + machine.addRoutes(event: .succeededSigningIn, transitions: [ .signingIn => .signedIn ]) + machine.addRoutes(event: .failedSigningIn, transitions: [ .signingIn => .signedOut ]) + + machine.addRoutes(event: .showHomeScreen, transitions: [ .signedIn => .homeScreen ]) + + machine.addRoutes(event: .attemptSignOut, transitions: [ .homeScreen => .signingOut ]) + + machine.addRoutes(event: .succeededSigningOut, transitions: [ .signingOut => .signedOut ]) + machine.addRoutes(event: .failedSigningOut, transitions: [ .signingOut => .homeScreen ]) + + // Transitions with associated values need to be handled through `addRouteMapping` + machine.addRouteMapping { event, fromState, _ in + switch (event, fromState) { + case (.showRoomScreen(let roomId), .homeScreen): + return .roomScreen(roomId: roomId) + case (.dismissedRoomScreen, .roomScreen): + return .homeScreen + default: + return nil + } + } + } + } + + /// 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) { + stateMachine.tryEvent(event) + } + + /// Registers a callback for processing state machine transitions + func addTransitionHandler(_ handler: @escaping StateMachine.Handler) { + stateMachine.addAnyHandler(.any => .any, handler: handler) + } + + /// Registers a callback for processing state machine errors + func addErrorHandler(_ handler: @escaping StateMachine.Handler) { + stateMachine.addErrorHandler(handler: handler) + } +} diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 6aea88673..387a5d2e3 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -87,6 +87,7 @@ targets: - package: Kingfisher - package: Introspect - package: SwiftyBeaver + - package: SwiftState sources: - path: ../Sources diff --git a/UITests/SupportingFiles/target.yml b/UITests/SupportingFiles/target.yml index d1d5784a9..e08327c74 100644 --- a/UITests/SupportingFiles/target.yml +++ b/UITests/SupportingFiles/target.yml @@ -19,6 +19,8 @@ targets: linkType: static - package: SwiftyBeaver linkType: static + - package: SwiftState + linkType: static info: path: ../SupportingFiles/Info.plist diff --git a/project.yml b/project.yml index d05610521..990b90c7b 100644 --- a/project.yml +++ b/project.yml @@ -48,3 +48,6 @@ packages: SwiftyBeaver: url: https://github.com/SwiftyBeaver/SwiftyBeaver majorVersion: 1.9.5 + SwiftState: + url: https://github.com/ReactKit/SwiftState + majorVersion: 6.0.0