halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps
What every Halkyon app's shell needs, taken from Halkyon Learn's and set on halkyon_base's client: the instances a device keeps an account on, the first start's tiles and where an instance keeps people's data, the account switcher; signing in anonymously, with GitHub or an email code, switching person and the start screen; the gates that build an app's dependencies per instance and per person; and the sync service with its status, Settings card and banner. Texts of its own in English and German take the app's name; what only the app can say, it passes in. Fakes for apps' tests are in testing.dart. An instance's basics now name the app it serves (`app`, set by the host's configuration), and an app accepts only an instance of its own. SerialQueue and CoalescingRunner move into halkyon_core. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
af1de799f4
commit
e23cc77f7c
1 changed files with 10761 additions and 10 deletions
249
packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart
Normal file
249
packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
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/data/signed_out_mark.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, signs in anonymously
|
||||
/// at a first start, and signs out on request. Once signed out (by the
|
||||
/// person, by deleting the account, or by the server ending the session),
|
||||
/// it asks how to start rather than making an account by itself, also at
|
||||
/// every start of the app until someone starts.
|
||||
class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
|
||||
extends Bloc<SessionEvent, SessionState> {
|
||||
this : super(const SessionStateLoading()) {
|
||||
on<SessionEventStarted>(_onStarted);
|
||||
on<SessionEventSignInRequested>(_onSignInRequested);
|
||||
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
|
||||
on<SessionEventGitHubRequested>(_onGitHubRequested);
|
||||
on<SessionEventEmailRequested>(_onEmailRequested);
|
||||
on<SessionEventSignedOut>(
|
||||
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
|
||||
);
|
||||
on<SessionEventSignOutRequested>(_onSignOutRequested);
|
||||
on<SessionEventDeleteRequested>(_onDeleteRequested);
|
||||
}
|
||||
|
||||
/// Whether an email sign-in follows the anonymous start under way.
|
||||
var _thenEmail = false;
|
||||
|
||||
Future<void> _onStarted(
|
||||
SessionEventStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
if (_auth.status is SignedOut && await _mark.read()) {
|
||||
emit(const SessionStateSignedOut());
|
||||
} else {
|
||||
add(const SessionEventSignInRequested());
|
||||
}
|
||||
await emit.forEach<AuthStatus>(
|
||||
_auth.statusChanges,
|
||||
onData: (status) {
|
||||
if (status is SignedOut) {
|
||||
// Signed out, here or by the server (a revoked session): ask how
|
||||
// to start, now and at the next starts.
|
||||
unawaited(_mark.write(signedOut: true));
|
||||
return const SessionStateSignedOut();
|
||||
}
|
||||
unawaited(_mark.write(signedOut: false));
|
||||
return _stateOf(status, signsInWithEmail: _thenEmail);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSignInRequested(
|
||||
SessionEventSignInRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
// A session stored on the device wins: never a second anonymous account.
|
||||
if (_auth.status case final SignedIn person) {
|
||||
emit(SessionStateActive(person));
|
||||
return;
|
||||
}
|
||||
emit(const SessionStateLoading());
|
||||
// Read before signing in: the status change on the way shows the app
|
||||
// too, and must ask for the address as well.
|
||||
final email = _thenEmail;
|
||||
try {
|
||||
await _auth.ensureSignedIn();
|
||||
_thenEmail = false;
|
||||
emit(_stateOf(_auth.status, signsInWithEmail: email));
|
||||
} on Object catch (error, st) {
|
||||
_thenEmail = false;
|
||||
addError(error, st);
|
||||
emit(const SessionStateFailed());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onStartedAnonymously(
|
||||
SessionEventStartedAnonymously event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
await _mark.write(signedOut: false);
|
||||
add(const SessionEventSignInRequested());
|
||||
}
|
||||
|
||||
/// Signing in with GitHub needs no account here first: the GitHub
|
||||
/// account's person, if there is one.
|
||||
Future<void> _onGitHubRequested(
|
||||
SessionEventGitHubRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionStateSignedOut(busy: true));
|
||||
try {
|
||||
if (!await _auth.switchToGitHub()) {
|
||||
emit(const SessionStateSignedOut());
|
||||
}
|
||||
// Signed in: the status change shows the person's app.
|
||||
} on GitHubSignInFailed catch (e) {
|
||||
emit(
|
||||
SessionStateSignedOut(
|
||||
problem: e.problem == GitHubSignInProblem.unknownAccount
|
||||
? SignedOutProblem.noGitHubAccount
|
||||
: SignedOutProblem.failed,
|
||||
),
|
||||
);
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
|
||||
}
|
||||
}
|
||||
|
||||
/// Email codes are only taken from someone signed in: an anonymous start
|
||||
/// first, then the address, which switches to its person if it has one.
|
||||
Future<void> _onEmailRequested(
|
||||
SessionEventEmailRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
_thenEmail = true;
|
||||
await _mark.write(signedOut: false);
|
||||
add(const SessionEventSignInRequested());
|
||||
}
|
||||
|
||||
Future<void> _onSignOutRequested(
|
||||
SessionEventSignOutRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionStateLoading());
|
||||
try {
|
||||
// The status change to signed out then asks how to start.
|
||||
await _auth.signOut();
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(_stateOf(_auth.status));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleteRequested(
|
||||
SessionEventDeleteRequested event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
emit(const SessionStateLoading());
|
||||
try {
|
||||
// The status change to signed out then asks how to start.
|
||||
await _auth.deleteAccount();
|
||||
} on Object catch (error, st) {
|
||||
addError(error, st);
|
||||
emit(_stateOf(_auth.status));
|
||||
}
|
||||
}
|
||||
|
||||
SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) =>
|
||||
switch (status) {
|
||||
SignedIn() => SessionStateActive(
|
||||
status,
|
||||
signsInWithEmail: signsInWithEmail,
|
||||
),
|
||||
SignedOut() => const SessionStateSignedOut(),
|
||||
};
|
||||
}
|
||||
|
||||
/// What happens to the session.
|
||||
sealed class const SessionEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// The app starts: follow who is signed in, and sign in anonymously if nobody
|
||||
/// is.
|
||||
final class const SessionEventStarted() extends SessionEvent;
|
||||
|
||||
/// Sign in (again): after a failed attempt, or once nobody is signed in.
|
||||
final class const SessionEventSignInRequested() extends SessionEvent;
|
||||
|
||||
/// Start without an account, once signed out: a new anonymous one.
|
||||
final class const SessionEventStartedAnonymously() extends SessionEvent;
|
||||
|
||||
/// Sign in with GitHub, once signed out.
|
||||
final class const SessionEventGitHubRequested() extends SessionEvent;
|
||||
|
||||
/// Sign in with an email address, once signed out.
|
||||
final class const SessionEventEmailRequested() 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<Object?> get props => [problem];
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Signing in, or finding out who is signed in.
|
||||
final class const SessionStateLoading() extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Signing in failed, for example without a connection; the app offers to try
|
||||
/// again.
|
||||
final class const SessionStateFailed() extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// A person is signed in.
|
||||
final class const SessionStateActive(
|
||||
/// Who is signed in.
|
||||
final SignedIn person, {
|
||||
|
||||
/// Whether they asked to sign in with an email address, which their app
|
||||
/// asks for once it opens.
|
||||
final bool signsInWithEmail = false,
|
||||
}) extends SessionState {
|
||||
@override
|
||||
List<Object?> get props => [person, signsInWithEmail];
|
||||
}
|
||||
|
||||
/// Nobody is signed in since signing out: 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<Object?> get props => [busy, problem];
|
||||
}
|
||||
|
||||
/// Why a way to start failed.
|
||||
enum SignedOutProblem() {
|
||||
/// No account has the GitHub account yet: start without one, then add
|
||||
/// GitHub.
|
||||
noGitHubAccount,
|
||||
|
||||
/// Anything else, as no connection.
|
||||
failed,
|
||||
}
|
||||
Loading…
Reference in a new issue