feat(app): sign in with GitHub and keep everything

The account dialog offers "Sign in with GitHub" to anonymous people where the instance has GitHub sign-in. It links the GitHub account to the person, who keeps all their studysets. If the account already belongs to another person (typically their account from another device), the app switches to that person right away when this device holds nothing, and otherwise asks first: switch and leave this device's studysets behind, or stay anonymous.

ServerpodAuthGateway uses Serverpod's GitHubSignInService for the browser part (PKCE), with the system browser and a loopback address on Linux and Windows (http://127.0.0.1:43823/github-callback) rather than an embedded webview, and app.halkyon.learn://github-callback elsewhere; Android gets the callback activity. After linking, the device's copy of the session gets the identified scope. A switch signs in through a client without a session, swaps sessions directly (never through "signed out", which would start another anonymous account), then ends the anonymous session left behind.

GitHubSignInBloc holds the flow; tests cover every branch (GitHub sign-in off, linked, cancelled on GitHub, switching right away, asking with staying anonymous as the refusal, switching once confirmed, a failure), and the widget tests the dialog. StudysetRepository gains all(), a one-off read: Stream.first on its stream never completes under the widget tests' fake clock, because the broadcast subscription's cancel future belongs to the root zone; AGENTS.md records it along with the GitHub pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-09-30 13:35:51 +02:00
commit 312b18be5a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -2,9 +2,11 @@ import 'dart:async';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/github_link_outcome.dart';
/// An [AuthGateway] without a server: signing in creates people named
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail.
/// `anonymous-1`, `anonymous-2`, …, and [signInError] makes it fail. GitHub
/// sign-in links the signed-in person, or switches to `github-person`.
class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
implements AuthGateway {
final _changes = StreamController<AuthStatus>.broadcast();
@ -18,6 +20,18 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// How often signing out was attempted.
int signOuts = 0;
/// Whether the instance offers GitHub sign-in.
bool offersGitHub = true;
/// How the next GitHub links end.
GitHubLinkOutcome gitHubLinkOutcome = GitHubLinkOutcome.linked;
/// Thrown by the next GitHub sign-ins while set.
Exception? gitHubError;
/// How often this device switched to a GitHub person.
int gitHubSwitches = 0;
@override
AuthStatus get status => _status;
@ -42,6 +56,32 @@ class FakeAuthGateway({var AuthStatus _status = const SignedOut()})
/// Ends the session as when the server revokes this device's session.
void revokeSession() => _set(const SignedOut());
@override
Future<String?> gitHubClientId() async =>
offersGitHub ? 'github-client-id' : null;
@override
Future<GitHubLinkOutcome> linkGitHub() async {
if (gitHubError case final error?) {
throw error;
}
if (gitHubLinkOutcome == GitHubLinkOutcome.linked) {
final person = _status as SignedIn;
_set(SignedIn(authUserId: person.authUserId, isIdentified: true));
}
return gitHubLinkOutcome;
}
@override
Future<bool> switchToGitHub() async {
if (gitHubError case final error?) {
throw error;
}
gitHubSwitches++;
_set(const SignedIn(authUserId: 'github-person', isIdentified: true));
return true;
}
void _set(AuthStatus status) {
_status = status;
_changes.add(status);