feat(app): adding SimpleLogin to an account (ADR 0020, D21, D23)
All checks were successful
CI / check (push) Successful in 32m34s
CI / deploy (push) Has been skipped

halkyon_app reads where the device's person stands with SimpleLogin
(nothing to show, linked, or missing) and shows it in SimpleLoginSection,
which each app places above the password part: Add SimpleLogin, once
added one more way in. GitHub is added to nobody where SimpleLogin is
offered, and Learn's Profile offers an anonymous person SimpleLogin
alone there (D23).

No cut-off day, reminder or banner (D24): with the developer the only
person on learn.halkyon.eu, the cut-off is the operator taking GitHub's
OAuth app out of the deploy's settings once that account has SimpleLogin.
ADR 0020 records it and drops step 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 11:56:26 +02:00
commit fad12b4411
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 829 additions and 77 deletions

View file

@ -470,6 +470,7 @@
"profileSettingsTooltip": "Einstellungen",
"profileIdentified": "Dein Fortschritt wird in deinem Konto gespeichert.",
"profileAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"profileAnonymousSimpleLogin": "Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.",
"profileSignInWithGitHub": "Mit GitHub anmelden",
"profileSignInFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"profileContinueInBrowser": "Fahre in deinem Browser fort.",

View file

@ -535,6 +535,7 @@
"profileSettingsTooltip": "Settings",
"profileIdentified": "Your progress is kept with your account.",
"profileAnonymous": "Progress is saved on this device. Sign in to keep it.",
"profileAnonymousSimpleLogin": "Progress is saved on this device. Add SimpleLogin to keep it.",
"profileSignInWithGitHub": "Sign in with GitHub",
"profileSignInFailed": "Couldn't sign in with GitHub.",
"profileContinueInBrowser": "Continue in your browser.",

View file

@ -2830,6 +2830,12 @@ abstract class AppLocalizations {
/// **'Progress is saved on this device. Sign in to keep it.'**
String get profileAnonymous;
/// No description provided for @profileAnonymousSimpleLogin.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Add SimpleLogin to keep it.'**
String get profileAnonymousSimpleLogin;
/// No description provided for @profileSignInWithGitHub.
///
/// In en, this message translates to:

View file

@ -1719,6 +1719,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get profileAnonymous =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.';
@override
String get profileAnonymousSimpleLogin =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Füge SimpleLogin hinzu, um ihn zu behalten.';
@override
String get profileSignInWithGitHub => 'Mit GitHub anmelden';

View file

@ -1703,6 +1703,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get profileAnonymous =>
'Progress is saved on this device. Sign in to keep it.';
@override
String get profileAnonymousSimpleLogin =>
'Progress is saved on this device. Add SimpleLogin to keep it.';
@override
String get profileSignInWithGitHub => 'Sign in with GitHub';

View file

@ -342,6 +342,7 @@ class Dependencies({
onDispose: () async {
await deviceDictionaries.dispose();
await passwords.dispose();
await simpleLogin.dispose();
await authGateway.dispose();
client.close();
},

View file

@ -155,34 +155,40 @@ class const _Account() extends StatelessWidget {
),
children: [
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.settingsAccount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
isIdentified
? l10n.profileIdentified
: l10n.profileAnonymous,
),
if (isIdentified) const _SignInWays(),
if (!isIdentified) ...[
const _GitHubSignIn(),
const SizedBox(height: 16),
// A password keeps the account too (ADR 0017).
const AccountSecuritySection(),
const SizedBox(height: 16),
// Where the person stands with SimpleLogin (ADR 0020, D21,
// D23), read once for the card's parts that show it.
child: SimpleLoginStandingScope(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.publicProfileAnonymousHint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
l10n.settingsAccount,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
_AccountLine(identified: isIdentified),
if (isIdentified) const _SignInWays(),
if (!isIdentified) ...[
const _GitHubSignIn(),
// SimpleLogin keeps the account, where the instance
// offers it.
const SimpleLoginSection(gapAbove: 16),
const SizedBox(height: 16),
// A password keeps the account too (ADR 0017).
const AccountSecuritySection(),
const SizedBox(height: 16),
Text(
l10n.publicProfileAnonymousHint,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
],
],
),
),
),
if (isIdentified) ...[
@ -201,6 +207,26 @@ class const _Account() extends StatelessWidget {
}
}
/// What the account card says first: that the account keeps the progress,
/// or that only this device does, with what keeps it (SimpleLogin, where
/// the instance offers it).
class const _AccountLine({required final bool identified})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
if (identified) {
return Text(l10n.profileIdentified);
}
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
return Text(
simpleLogin is SimpleLoginMissing
? l10n.profileAnonymousSimpleLogin
: l10n.profileAnonymous,
);
}
}
bool _identifiedIn(SessionState state) => switch (state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
@ -274,6 +300,8 @@ class const _SignInWays() extends StatelessWidget {
l10n.profileWayEmail,
linkedAs: ways.emailAddress ?? l10n.profileWayLinked,
),
// SimpleLogin (ADR 0020, D21): once added, one more way in here.
const SimpleLoginSection(gapAbove: 16),
const SizedBox(height: 16),
// The password and authenticators, and setting them up (ADR 0017),
// which brings the address.
@ -286,31 +314,38 @@ class const _SignInWays() extends StatelessWidget {
/// The way to keep an anonymous account: signing in with GitHub, where the
/// instance offers it. Where people sign in with a password it reaches only
/// a GitHub account someone has (ADR 0017), and the password beneath keeps
/// the account instead.
/// the account instead. Not where the instance offers SimpleLogin, which
/// alone keeps it there (ADR 0020, D23), nor until the instance has said.
class const _GitHubSignIn() extends StatelessWidget {
@override
Widget build(BuildContext context) =>
BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
builder: (context, state) => switch (state) {
GitHubSignInStateChecking() ||
GitHubSignInStateUnavailable() ||
GitHubSignInStateLinked() => const SizedBox.shrink(),
GitHubSignInStateReady(:final failed, :final problem) => _button(
context,
enabled: true,
failed: failed,
problem: problem,
),
// Only for someone identified, who sees their ways in instead.
GitHubSignInStateTaken() => _button(context, enabled: true),
GitHubSignInStateBusy(cancellable: true) => const Padding(
padding: EdgeInsets.only(top: 16),
child: _GitHubWaiting(),
),
GitHubSignInStateBusy() ||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
},
);
Widget build(BuildContext context) {
final simpleLogin = context.watch<SimpleLoginStandingCubit>().state;
if (simpleLogin is SimpleLoginChecking ||
simpleLogin is SimpleLoginMissing) {
return const SizedBox.shrink();
}
return BlocBuilder<GitHubSignInBloc, GitHubSignInState>(
builder: (context, state) => switch (state) {
GitHubSignInStateChecking() ||
GitHubSignInStateUnavailable() ||
GitHubSignInStateLinked() => const SizedBox.shrink(),
GitHubSignInStateReady(:final failed, :final problem) => _button(
context,
enabled: true,
failed: failed,
problem: problem,
),
// Only for someone identified, who sees their ways in instead.
GitHubSignInStateTaken() => _button(context, enabled: true),
GitHubSignInStateBusy(cancellable: true) => const Padding(
padding: EdgeInsets.only(top: 16),
child: _GitHubWaiting(),
),
GitHubSignInStateBusy() ||
GitHubSignInStateConfirmSwitch() => _button(context, enabled: false),
},
);
}
Widget _button(
BuildContext context, {

View file

@ -0,0 +1,52 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
// Where the instance offers SimpleLogin (ADR 0020, D21, D23): it alone keeps
// an anonymous account.
void main() {
late FakeAuthGateway auth;
late FakeSimpleLoginGateway simpleLogin;
setUp(() {
auth = FakeAuthGateway();
simpleLogin = FakeSimpleLoginGateway(auth);
});
Future<void> start(WidgetTester tester) => pumpApp(
tester,
testDependencies(authGateway: auth, simpleLoginGateway: simpleLogin),
);
testWidgets('an anonymous person is offered SimpleLogin alone, not GitHub '
'(D23)', (tester) async {
await start(tester);
await openTab(tester, 'Profile');
expect(
find.text(
'Progress is saved on this device. Add SimpleLogin to keep it.',
),
findsOneWidget,
);
expect(find.text('Sign in with GitHub'), findsNothing);
expect(find.text('Add SimpleLogin'), findsWidgets);
});
testWidgets('without SimpleLogin, GitHub keeps the account as before', (
tester,
) async {
simpleLogin.isOffered = false;
await start(tester);
await openTab(tester, 'Profile');
expect(
find.text('Progress is saved on this device. Sign in to keep it.'),
findsOneWidget,
);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.textContaining('SimpleLogin'), findsNothing);
});
}

View file

@ -175,6 +175,8 @@ Dependencies testDependencies({
MediaPlayers? mediaPlayers,
PasswordGateway? passwordGateway,
AccountKeyStore? accountKeys,
// On the same `authGateway`; none offers SimpleLogin unless given.
FakeSimpleLoginGateway? simpleLoginGateway,
}) {
final device = deviceDictionaries ?? noDeviceDictionaries();
final auth = authGateway ?? FakeAuthGateway();
@ -192,10 +194,10 @@ Dependencies testDependencies({
auth,
addresses,
),
// Offered by no instance in Learn's tests: halkyon_app's tests test its
// sheet.
// Offered by no instance in Learn's tests but where one is given:
// halkyon_app's tests test its sheet.
simpleLogin: SimpleLoginRepository(
FakeSimpleLoginGateway(auth)..isOffered = false,
simpleLoginGateway ?? (FakeSimpleLoginGateway(auth)..isOffered = false),
FakeOidcBrowser(),
crypto,
keys,