Last active
December 14, 2023 09:42
-
-
Save buh/63e1dd41b267bb65baacf03b8557786a to your computer and use it in GitHub Desktop.
Example for a Finite-State Machine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// The authentication state. | |
enum AuthenticationState: StateType { | |
// A list of events. | |
enum Event { | |
case userSignIn(email: String, password: String) | |
case accessTokenReceived(AccessToken) | |
case userReceived(User) | |
case userSignedOut | |
} | |
// A list of states. | |
case userSignedOut | |
case userSigningIn(email: String, password: String) | |
case authenticated(AccessToken) | |
case signedIn(AccessToken, User) | |
// The initial value. | |
static var initial: Self = .userSignedOut | |
// The reducer function. | |
mutating func reduce(with event: Event) { | |
switch (event, self) { | |
// User is signing in with credentials. | |
case (.userSignIn(let email, let password), .userSignedOut): | |
self = .userSigningIn(email: email, password: password) | |
return | |
// Access token received when user was signed out. | |
case (.accessTokenReceived(let accessToken), .userSignedOut): | |
self = .authenticated(accessToken) | |
return | |
// Access token received when the user was already signed in. | |
// Just update the access token with a new one. | |
case (.accessTokenReceived(let accessToken), .signedIn(_, let user)): | |
self = .signedIn(accessToken, user) | |
return | |
} | |
// The user is received after authentication. | |
case (.userReceived(let user), .authenticated(let accessToken)): | |
self = .signedIn(accessToken, user) | |
return | |
} | |
// The user is received when the user was already signed in. | |
// Just update the user with a new one. | |
case (.userReceived(let user), .signedIn(let accessToken, _)): | |
self = .signedIn(accessToken, user) | |
return | |
} | |
// Simply sign out after the user's action. | |
case (.userSignedOut, _): | |
self = .userSignedOut | |
return | |
} | |
// It is important to stop the app in debug mode to deal with unhandled events. | |
assertionFailure("Unexpected behaviour for state: \(self). The event wasn't handled: \(event)") | |
} | |
} | |
// MARK: - State Extension | |
extention AuthenticationState { | |
/// Checks if the user is authenticated. | |
var isAuthenticated: Bool { | |
if case .authenticated = self { | |
return true | |
} | |
return isSignedIn | |
} | |
/// Checks if the user is signed in. | |
var isSignedIn: Bool { | |
if case .signedIn = self { | |
return true | |
} | |
return false | |
} | |
/// Checks if the user is signed out. | |
var isSignedOut: Bool { | |
if case .userSignedOut = self { | |
return true | |
} | |
return false | |
} | |
/// Checks if the authentication is in progress. | |
var isSigningIn: Bool { | |
if case .userSigningIn = self { | |
return true | |
} | |
if case .authenticated = self { | |
return true | |
} | |
return false | |
} | |
/// Returns the authenticated user. | |
var user: User? { | |
if case .signedIn(_, let user) = self { | |
return user | |
} | |
return nil | |
} | |
} | |
// MARK: - Authentication Manager | |
final class AuthenticationManager { | |
// The state already has an initial value, | |
// so there is no need to initialise it when registering. | |
@Registered var state: AuthenticationState | |
// Inject a client dependency to make requests to the server. | |
@Injected var client: Client | |
/// Define the state reducer in the manager, | |
/// because only the manager has access to change it. | |
func reduceState(event: AuthenticationState.Event) { | |
_state.reduceState(event: event) | |
// We can also carry out additional activities | |
// as part of the manager's responsibility. | |
if case .authenticated = state { | |
fetchUser { error in /* ... */ } | |
} | |
} | |
/// Sign-in with credentionals. | |
/// In the completion block, we don't need to return a new state. | |
/// It needs to be changed via the reducer function and thus | |
/// its subscribers will be able to receive the new value. | |
func signIn(email: String, password: String, _ completion: @escaping (ClientError?) -> Void) { | |
reduceState(event: .userSignIn(email: email, password: password)) | |
client.post(.signIn(email: email, password: password)) { [unowned self] (result: Result<AccessToken, ClientError>) in | |
do { | |
let accessToken = try result.get() | |
reduceState(event: .accessTokenReceived(accessToken)) | |
completion(nil) | |
} catch { | |
completion(error) | |
} | |
} | |
} | |
/// Fetch a user object. | |
func fetchUser(_ completion: @escaping (ClientError?) -> Void) { | |
client.get(.user) { [unowned self] (result: Result<User, ClientError>) in | |
do { | |
let user = try result.get() | |
reduceState(event: .userReceived(user)) | |
completion(nil) | |
} catch { | |
completion(error) | |
} | |
} | |
} | |
} | |
// MARK: - Root View Controller | |
final class RootViewController: UIViewController { | |
@Registered var authenticationManager: AuthenticationManager | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
// Subscribe to changes in authentication status. | |
onChange(authenticationManager.$state) { [weak self] in | |
self?.handleAuthenticationState($0) | |
} | |
} | |
// Handles the authentication state to show the corresponding view controller. | |
private func handleAuthenticationState(_ state: AuthenticationState) { | |
if state.isSignedOut { | |
showLoginViewController() | |
} else if state.isSignedIn { | |
showContentViewController() | |
} | |
} | |
// ... some implementation for showLoginViewController() and showContentViewController() | |
} | |
// MARK: - Login View Controller | |
final class LoginViewController: UIViewController { | |
// As the AuthenticationManager has already been registered | |
// in the parent RootViewController it can be injected here. | |
@Injected var authenticationManger: AuthenticationManager | |
/* UI elements */ | |
private func loginButtonDidTap() { | |
guard let email = emailTextField.text, | |
!email.isEmpty | |
let password = passwordTextField.text, | |
!password.isEmpty else { | |
// Show the message with incorrect fields. | |
return | |
} | |
// Sends a sign-in request and if success the RootViewController will | |
// dismiss this view controller and show the ContentViewController. | |
authenticationManger.signIn(email: email, password: password) { [weak self] error in | |
if let error = error { | |
self?.showErrorAlert(error) | |
} | |
} | |
} | |
} | |
// MARK: - Content View Controller | |
final class ContentViewController: UIViewController { | |
// Inject the user from the AuthenticatedState and every time | |
// the user data is updated we get an updated object. | |
@Injected(\AuthenticationState.user) var user: User? | |
private let usernameLabel = UILabel(frame: .zero) | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
setupUI() | |
onChange($user) { [usernameLabel] user in | |
usernameLabel.text = "Welcome, \(user?.name ?? "user")!" | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment