halkyon/packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart
Tizian 「ティツィアーン」 5b9df73756
feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)
Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 16:14:37 +02:00

270 lines
9 KiB
Dart

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<SessionEvent, SessionState> {
this : super(const SessionStateLoading()) {
on<SessionEventStarted>(_onStarted);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventPasswordOfferDone>(
(event, emit) => emit(_stateOf(_auth.status)),
);
on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
);
on<SessionEventSignOutRequested>(_onSignOutRequested);
on<SessionEventDeleteRequested>(_onDeleteRequested);
}
Future<void> _onStarted(
SessionEventStarted event,
Emitter<SessionState> 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<AuthStatus>(_auth.statusChanges, onData: _stateOf);
}
/// Signing in with GitHub: the GitHub account's person, if there is one.
Future<void> _onGitHubRequested(
SessionEventGitHubRequested event,
Emitter<SessionState> 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<void> _onSignOutRequested(
SessionEventSignOutRequested event,
Emitter<SessionState> 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<void> _onDeleteRequested(
SessionEventDeleteRequested event,
Emitter<SessionState> 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<void> _onAccountAction(
SessionAction busy,
Future<void> Function() action,
SessionProblem problem,
Emitter<SessionState> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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,
}