feat(app): leaving the profile tab cancels a waiting GitHub sign-in

Decided 2026-10-01, in place of "closing the account dialog cancels it",
which had no dialog left once the account moved into the Profile tab:
choosing another tab cancels a sign-in waiting for GitHub in the browser,
since the person has left it behind. Settings, opened over the profile, is
not leaving it.

Screens learn whether their tab shows from TabVisibility, which the tabs'
container now provides: go_router's indexed stack as before (every tab
alive, hidden ones offstage with their animations paused), each tab wrapped
in it.

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-01 08:48:45 +02:00
commit 8e0f5cf538
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 89 additions and 17 deletions

View file

@ -90,7 +90,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute.indexedStack`, each keeping its own stack of screens; `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below, and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library); what needs the whole window (studying, a card, settings) is a top-level route over the tabs, reached with `context.push`. Explore stays empty until something is shared.
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings); `PaletteColor` for the 20 colours a person can give content. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`); the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`).
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/app_en.arb`; English only for now): no string a person reads is written into a widget, except in older screens until they are restyled. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` file.
@ -120,7 +120,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), a change of person, or 5 minutes, also while another tab is open; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **On Linux and Windows the browser half is the app's own** (`LoopbackWebAuth`, `feature/auth/data/`): the system browser and a loopback address (RFC 8252), put in place of flutter_web_auth_2's own implementation (checked with v5.1.0) before each sign-in. The package's front ends every pending sign-in when the app is resumed, and on the desktop the window regaining focus resumes it, so its loopback server closed before GitHub answered. Ours waits for GitHub, the profile's Cancel (shown only there), choosing another tab, a change of person, or 5 minutes; the regression test drives the package's front through a focus change. Remove it once the package stops ending sign-ins on focus there. Serverpod (v4.0.3) reports every cancelled browser round trip as an unknown error, so the gateway knows a cancel only by having asked for it; a cancel on GitHub's own page, or leaving the browser on Android, still shows "Couldn't sign in with GitHub."
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.

View file

@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/found_by_id.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_edit_bloc.dart';
@ -27,9 +28,10 @@ import 'package:material_ui/material_ui.dart' hide Card;
GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
initialLocation: initialLocation,
routes: [
StatefulShellRoute.indexedStack(
StatefulShellRoute(
builder: (context, state, navigationShell) =>
AppShell(navigationShell: navigationShell),
navigatorContainerBuilder: tabsContainer,
// In the order of the shell's destinations.
branches: [
StatefulShellBranch(

View file

@ -0,0 +1,47 @@
import 'package:go_router/go_router.dart';
import 'package:material_ui/material_ui.dart';
/// Whether the tab a screen is in is the one showing. The other tabs' screens
/// stay alive while hidden, keeping their place; a screen that should stop
/// something once its tab is out of view depends on this.
class const TabVisibility({
/// Whether the tab shows.
required final bool visible,
required super.child,
super.key,
}) extends InheritedWidget {
/// Whether the tab of [context] shows; true outside the tabs, such as on a
/// screen covering them.
static bool of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<TabVisibility>()?.visible ??
true;
@override
bool updateShouldNotify(TabVisibility oldWidget) =>
visible != oldWidget.visible;
}
/// The container of the tabs' navigators for a `StatefulShellRoute`, as
/// go_router's indexed stack builds it: every tab stays alive, the hidden
/// ones offstage with their animations paused. Each also learns from
/// [TabVisibility] whether it shows.
Widget tabsContainer(
BuildContext context,
StatefulNavigationShell shell,
List<Widget> tabs,
) => IndexedStack(
index: shell.currentIndex,
children: [
for (final (index, tab) in tabs.indexed)
Offstage(
offstage: index != shell.currentIndex,
child: TickerMode(
enabled: index == shell.currentIndex,
child: TabVisibility(
visible: index == shell.currentIndex,
child: tab,
),
),
),
],
);

View file

@ -5,6 +5,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.d
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
@ -35,12 +36,37 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
const SizedBox(width: 4),
],
),
body: const _Account(),
body: const _CancelWhenLeft(child: _Account()),
),
);
}
}
/// Cancels a sign-in waiting for GitHub in the browser once another tab is
/// chosen: the person has left it behind (decided 2026-10-01). Settings,
/// opened over this tab, doesn't count as leaving.
class const _CancelWhenLeft({required final Widget child})
extends StatefulWidget {
@override
State<_CancelWhenLeft> createState() => _CancelWhenLeftState();
}
class _CancelWhenLeftState() extends State<_CancelWhenLeft> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!TabVisibility.of(context)) {
final bloc = context.read<GitHubSignInBloc>();
if (bloc.state case GitHubSignInStateBusy(cancellable: true)) {
bloc.add(const GitHubSignInEventCancelled());
}
}
}
@override
Widget build(BuildContext context) => widget.child;
}
class const _Account() extends StatelessWidget {
@override
Widget build(BuildContext context) {

View file

@ -116,36 +116,33 @@ void main() {
expect(find.text('Continue in your browser.'), findsNothing);
});
testWidgets('going to another tab keeps waiting for GitHub', (
testWidgets('choosing another tab cancels it', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
await openTab(tester, 'Home');
expect(auth.gitHubCancels, 1);
await openTab(tester, 'Profile');
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text("Couldn't sign in with GitHub."), findsNothing);
});
testWidgets('settings, opened over the profile, keep it waiting', (
tester,
) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
await openTab(tester, 'Home');
await openTab(tester, 'Profile');
await tester.tap(find.byTooltip('Settings'));
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(auth.gitHubCancels, 0);
expect(find.text('Continue in your browser.'), findsOneWidget);
});
testWidgets('GitHub answering while another tab is open signs in', (
tester,
) async {
auth.canCancelGitHub = true;
await startSignIn(tester);
await openTab(tester, 'Home');
auth.answerGitHub();
await tester.pumpAndSettle();
await openTab(tester, 'Profile');
expect(
find.text('Your progress is kept with your account.'),
findsOneWidget,
);
});
testWidgets('a change of person cancels it', (tester) async {
auth.canCancelGitHub = true;
await startSignIn(tester);