#66 - Rebuild the AppCoordinator on top of a SwiftState FSM

This commit is contained in:
Stefan Ceriu
2022-05-27 13:02:36 +03:00
parent 61d28aab1c
commit a049923f21
7 changed files with 230 additions and 26 deletions

View File

@@ -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 = "<group>"; };
5773C86AF04AEF26515AD00C /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = "<group>"; };
5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModelTests.swift; sourceTree = "<group>"; };
5A9AB74614131D6706894E0C /* AppCoordinatorStateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCoordinatorStateMachine.swift; sourceTree = "<group>"; };
5B9D5F812E5AD6DC786DBC9B /* NavigationRouterStoreProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRouterStoreProtocol.swift; sourceTree = "<group>"; };
5CB7F9D6FC121204D59E18DF /* Presentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Presentable.swift; sourceTree = "<group>"; };
5D26A086A8278D39B5756D6F /* project.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = project.yml; sourceTree = "<group>"; };
@@ -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" */;

View File

@@ -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",

View File

@@ -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"))
}
}

View File

@@ -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<State, Event>
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<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)
}
}

View File

@@ -87,6 +87,7 @@ targets:
- package: Kingfisher
- package: Introspect
- package: SwiftyBeaver
- package: SwiftState
sources:
- path: ../Sources

View File

@@ -19,6 +19,8 @@ targets:
linkType: static
- package: SwiftyBeaver
linkType: static
- package: SwiftState
linkType: static
info:
path: ../SupportingFiles/Info.plist

View File

@@ -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