import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:halkyon_app/src/auth/data/auth_repository.dart'; import 'package:halkyon_app/src/auth/model/auth_status.dart'; import 'package:halkyon_app/src/auth/model/github_sign_in.dart'; /// Keeps a person signed in: follows who is signed in, and signs out on /// request. Nobody signed in (at a first start, after signing out or /// deleting the account, or once the server ended the session), it asks /// how to start: every account has a way in, and nothing is made until the /// person chooses (ADR 0020, D4). An anonymous account's session left from /// before they went ends as the app starts, the account deleted (D25). class SessionBloc(final AuthRepository _auth) extends Bloc { this : super(const SessionStateLoading()) { on(_onStarted); on(_onGitHubRequested); on( (event, emit) => emit(_stateOf(_auth.status)), ); on( (event, emit) => emit(SessionStateSignedOut(problem: event.problem)), ); on(_onSignOutRequested); on(_onDeleteRequested); } Future _onStarted( SessionEventStarted event, Emitter emit, ) async { if (_auth.isAnonymous) { try { await _auth.endAnonymousSession(); } on Object catch (error, st) { // Only the device's own storage fails it: the session stays, but // nothing works in it any more ([_stateOf]). addError(error, st); } } emit(_stateOf(_auth.status)); await emit.forEach(_auth.statusChanges, onData: _stateOf); } /// Signing in with GitHub: the GitHub account's person, if there is one. Future _onGitHubRequested( SessionEventGitHubRequested event, Emitter emit, ) async { emit(const SessionStateSignedOut(busy: true)); // The person signed in with GitHub is offered a password first (ADR // 0017, D2: "at their next sign-in"), while that sign-in is recent // enough for the instance to take one (the fourth reading). _offerPassword = true; try { if (!await _auth.switchToGitHub()) { _offerPassword = false; emit(const SessionStateSignedOut()); } // Signed in: the status change shows the offer, then the person's // app. } on GitHubSignInFailed catch (e) { _offerPassword = false; emit( SessionStateSignedOut( problem: switch (e.problem) { GitHubSignInProblem.unknownAccount => SignedOutProblem.noGitHubAccount, GitHubSignInProblem.usePassword => SignedOutProblem.usePassword, GitHubSignInProblem.ended => SignedOutProblem.gitHubEnded, GitHubSignInProblem.deprecated || GitHubSignInProblem.refused => SignedOutProblem.failed, }, ), ); } on Object catch (error, st) { _offerPassword = false; addError(error, st); emit(const SessionStateSignedOut(problem: SignedOutProblem.failed)); } } /// Whether the next person signed in comes from a GitHub sign-in, and is /// offered a password before their app ([SessionStateGitHubSignedIn]). bool _offerPassword = false; Future _onSignOutRequested( SessionEventSignOutRequested event, Emitter emit, ) => _onAccountAction( SessionAction.signOut, // The instance out of reach signs out on this device all the same // (Serverpod's `signOutDevice`), as signing out of a borrowed device // must work offline; only the device's own storage fails it. _auth.signOut, SessionProblem.signOutFailed, emit, ); Future _onDeleteRequested( SessionEventDeleteRequested event, Emitter emit, ) => _onAccountAction( SessionAction.delete, _auth.deleteAccount, SessionProblem.deleteFailed, emit, ); /// Runs [action] for the signed-in person's account, their app in place /// meanwhile, [busy] saying what is under way: never through the launch /// screen ([SessionStateLoading]), which would close the app and, should /// [action] fail, open it again with no word of why. Done, the status /// change to signed out asks how to start; failed, the person stays, /// told so by [problem]. Future _onAccountAction( SessionAction busy, Future Function() action, SessionProblem problem, Emitter emit, ) async { if (_auth.status case final SignedIn person) { emit(SessionStateActive(person, busy: busy)); } try { await action(); } on Object catch (error, st) { addError(error, st); emit(switch (_auth.status) { final SignedIn person => SessionStateActive(person, problem: problem), SignedOut() => const SessionStateSignedOut(), }); } } SessionState _stateOf(AuthStatus status) => switch (status) { // Nothing works in an anonymous account any more (D25): only one that // began before the app started could be signed in, and it has ended. SignedIn(isIdentified: false) => const SessionStateSignedOut(), SignedIn() when _offerPassword => _offered(status), SignedIn() => SessionStateActive(status), SignedOut() => const SessionStateSignedOut(), }; SessionState _offered(SignedIn person) { _offerPassword = false; return SessionStateGitHubSignedIn(person); } } /// What happens to the session. sealed class const SessionEvent() with Equatable { @override List get props => const []; } /// The app starts: follow who is signed in, and end an anonymous account's /// session left from before (ADR 0020, D25). final class const SessionEventStarted() extends SessionEvent; /// Sign in with GitHub, once signed out. final class const SessionEventGitHubRequested() extends SessionEvent; /// Signed out, [problem] if any saying why the last way to start failed. final class const SessionEventSignedOut({final SignedOutProblem? problem}) extends SessionEvent { @override List get props => [problem]; } /// The password offered after a GitHub sign-in was set up, put off, or not /// needed: on to the person's app. final class const SessionEventPasswordOfferDone() extends SessionEvent; /// The person confirmed that they want to sign out. final class const SessionEventSignOutRequested() extends SessionEvent; /// Delete the signed-in person with everything they have; the person has /// confirmed it. final class const SessionEventDeleteRequested() extends SessionEvent; /// Whether the app has a signed-in person to work for. sealed class const SessionState() with Equatable; /// Finding out who is signed in. final class const SessionStateLoading() extends SessionState { @override List get props => const []; } /// A person is signed in. final class const SessionStateActive( /// Who is signed in. final SignedIn person, { /// What is under way for the account (signing out, deleting it), while /// the person's app stays; null for nothing. final SessionAction? busy, /// Why the last of those failed, the person still signed in; null for /// none. final SessionProblem? problem, }) extends SessionState { @override List get props => [person, busy, problem]; } /// What the person asked for their account, under way while their app /// stays. enum SessionAction() { /// Signing out. signOut, /// Deleting the account. delete, } /// Why signing out or deleting the account failed, the person still signed /// in. enum SessionProblem() { /// Signing out failed on this device: the instance not reached signs out /// here all the same, so only the device's own storage fails it. signOutFailed, /// Deleting the account failed, the instance not reached or refusing: /// nothing deleted. deleteFailed, } /// [person] signed in with GitHub just now: before their app, a password /// is offered where the instance offers one and they have none (ADR 0017, /// D2), while the sign-in is recent enough to set it. final class const SessionStateGitHubSignedIn( /// Who signed in. final SignedIn person, ) extends SessionState { @override List get props => [person]; } /// Nobody is signed in: the app asks how to start. final class const SessionStateSignedOut({ /// Whether a way to start is under way. final bool busy = false, /// Why the last way to start failed; null for none. final SignedOutProblem? problem, }) extends SessionState { @override List get props => [busy, problem]; } /// Why a way to start failed. enum SignedOutProblem() { /// No account has the GitHub account: GitHub makes none (ADR 0020, D25), /// so the person creates one where the instance offers a way. noGitHubAccount, /// The GitHub account's person signs in with their address and password /// now (ADR 0017). usePassword, /// GitHub signs nobody in here any more. gitHubEnded, /// Anything else, as no connection. failed, }