feat(app): About Halkyon Learn, with the licences and the source

The account dialog opens Flutter's about dialog: the licences (AGPL-3.0-or-later for the app, SSPL-1.0 for the server), the address of the source the instance names, as selectable text, and "View licenses" with every package's notice, which the BSD and MIT packages require in a distributed app. The address comes through a small about feature (gateway, repository, bloc) that says so when the instance can't be reached. Tests cover the bloc both ways and the way from the account dialog; AGENTS.md records the obligation.

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 16:19:46 +02:00
commit 38843dbff2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 225 additions and 0 deletions

View file

@ -122,6 +122,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables. - **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device. - **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog. - **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them. - **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in. - **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in.
- **Linux builds need** `libwebkit2gtk-4.1-dev` and `libsecret-1-dev`. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`. - **Linux builds need** `libwebkit2gtk-4.1-dev` and `libsecret-1-dev`. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.

View file

@ -4,6 +4,8 @@ import 'package:halkyon_learn_client/halkyon_learn_client.dart'
show Client, UuidValue; show Client, UuidValue;
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart'; import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
@ -29,6 +31,9 @@ class Dependencies({
/// Who is signed in, and signing in and out. /// Who is signed in, and signing in and out.
required final AuthRepository auth, required final AuthRepository auth,
/// What the instance says about itself, such as where its source is.
required final AboutRepository about,
/// Opens a signed-in person's local data (see `PersonScope`). /// Opens a signed-in person's local data (see `PersonScope`).
required final OpenPerson openPerson, required final OpenPerson openPerson,
final Future<void> Function()? _onDispose, final Future<void> Function()? _onDispose,
@ -48,6 +53,7 @@ class Dependencies({
final authGateway = ServerpodAuthGateway(client); final authGateway = ServerpodAuthGateway(client);
return Dependencies( return Dependencies(
auth: AuthRepository(authGateway), auth: AuthRepository(authGateway),
about: AboutRepository(ServerpodAboutGateway(client)),
openPerson: (person) => _openPerson(client, person), openPerson: (person) => _openPerson(client, person),
onDispose: () async { onDispose: () async {
await authGateway.dispose(); await authGateway.dispose();

View file

@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
/// Finds where the source of what the instance runs is, for the About dialog.
class AboutBloc(final AboutRepository _about)
extends Bloc<AboutEvent, AboutState> {
this : super(const AboutStateLoading()) {
on<AboutEventStarted>(_onStarted);
}
Future<void> _onStarted(
AboutEventStarted event,
Emitter<AboutState> emit,
) async {
try {
emit(AboutStateLoaded(await _about.sourceCodeUrl()));
} on Exception catch (error, st) {
addError(error, st);
emit(const AboutStateUnreachable());
}
}
}
/// What happens to the About dialog.
sealed class const AboutEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The About dialog opens.
final class const AboutEventStarted() extends AboutEvent;
/// What the About dialog knows about the instance.
sealed class const AboutState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Asking the instance.
final class const AboutStateLoading() extends AboutState;
/// Where the source of what the instance runs is.
final class const AboutStateLoaded(
/// Its address.
final String sourceCodeUrl,
) extends AboutState {
@override
List<Object?> get props => [sourceCodeUrl];
}
/// The instance couldn't be reached.
final class const AboutStateUnreachable() extends AboutState;

View file

@ -0,0 +1,6 @@
/// The boundary to what the instance says about itself. Tests replace it with
/// a fake.
abstract interface class AboutGateway() {
/// Where the source of what the instance runs is.
Future<String> sourceCode();
}

View file

@ -0,0 +1,8 @@
import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart';
/// What the instance says about itself, as the About dialog shows it.
class AboutRepository(final AboutGateway _gateway) {
/// Where the source of what the instance runs is: both licences require
/// offering it to the people who use the instance.
Future<String> sourceCodeUrl() => _gateway.sourceCode();
}

View file

@ -0,0 +1,9 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart' show Client;
import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart';
/// [AboutGateway] asking the instance through the Serverpod client.
class ServerpodAboutGateway(final Client _client) implements AboutGateway {
@override
Future<String> sourceCode() async =>
(await _client.instance.info()).sourceCodeUrl;
}

View file

@ -0,0 +1,45 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/about/bloc/about_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:material_ui/material_ui.dart';
/// Shows what Halkyon Learn is under which licences, and where the source of
/// what this instance runs is; its "View licenses" lists every package's
/// notice, which the BSD and MIT packages require in a distributed app.
void showAbout(BuildContext context, AboutRepository about) => showAboutDialog(
context: context,
applicationName: 'Halkyon Learn',
applicationLegalese:
'Free software: the app is under the GNU Affero General Public License, '
'version 3 or any later version, the server under the Server Side '
'Public License, version 1.',
children: [
BlocProvider(
create: (context) => AboutBloc(about)..add(const AboutEventStarted()),
child: const _SourceCode(),
),
],
);
class const _SourceCode() extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(top: 16),
child: BlocBuilder<AboutBloc, AboutState>(
builder: (context, state) => switch (state) {
AboutStateLoading() => const SizedBox.shrink(),
AboutStateLoaded(:final sourceCodeUrl) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('The source of what this instance runs:'),
SelectableText(sourceCodeUrl),
],
),
AboutStateUnreachable() => const Text(
"Couldn't reach this instance to ask where its source is.",
),
},
),
);
}

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/github_sign_in_bloc.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'; import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -68,6 +69,12 @@ class const _AccountDialog() extends StatelessWidget {
: 'Progress is saved on this device. Sign in to keep it.', : 'Progress is saved on this device. Sign in to keep it.',
), ),
if (!isIdentified) const _GitHubSignIn(), if (!isIdentified) const _GitHubSignIn(),
const SizedBox(height: 8),
TextButton(
onPressed: () =>
showAbout(context, DependenciesScope.of(context).about),
child: const Text('About Halkyon Learn'),
),
], ],
), ),
actions: [ actions: [

View file

@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/about/bloc/about_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/account_button.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_about_gateway.dart';
import '../../support/test_dependencies.dart';
void main() {
group('AboutBloc', () {
test('finds where the instance says its source is', () async {
final bloc = AboutBloc(AboutRepository(FakeAboutGateway()))
..add(const AboutEventStarted());
addTearDown(bloc.close);
await expectLater(
bloc.stream,
emits(const AboutStateLoaded('https://example.org/halkyon-learn')),
);
});
test('says so when the instance cannot be reached', () async {
final gateway = FakeAboutGateway()
..unreachable = Exception('No connection');
final bloc = AboutBloc(AboutRepository(gateway))
..add(const AboutEventStarted());
addTearDown(bloc.close);
await expectLater(bloc.stream, emits(const AboutStateUnreachable()));
});
});
testWidgets('the account dialog leads to the licences and the source', (
tester,
) async {
await tester.pumpWidget(
DependenciesScope(
dependencies: testDependencies(),
child: MaterialApp(
home: SessionGate(
child: Scaffold(
appBar: AppBar(actions: const [AccountButton()]),
body: const Text('Home'),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Account'));
await tester.pumpAndSettle();
await tester.tap(find.text('About Halkyon Learn'));
await tester.pumpAndSettle();
expect(
find.textContaining('GNU Affero General Public License'),
findsOneWidget,
);
expect(find.text('https://example.org/halkyon-learn'), findsOneWidget);
expect(find.text('View licenses'), findsOneWidget);
});
}

View file

@ -0,0 +1,19 @@
import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart';
/// An [AboutGateway] without a server, naming [sourceCodeUrl]; [unreachable]
/// makes it fail as when the instance can't be reached.
class FakeAboutGateway({
/// Where the instance says its source is.
final String sourceCodeUrl = 'https://example.org/halkyon-learn',
}) implements AboutGateway {
/// Thrown by the next calls while set.
Exception? unreachable;
@override
Future<String> sourceCode() async {
if (unreachable case final error?) {
throw error;
}
return sourceCodeUrl;
}
}

View file

@ -1,5 +1,7 @@
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
@ -7,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'fake_about_gateway.dart';
import 'fake_auth_gateway.dart'; import 'fake_auth_gateway.dart';
import 'fake_card_gateway.dart'; import 'fake_card_gateway.dart';
import 'fake_studyset_gateway.dart'; import 'fake_studyset_gateway.dart';
@ -21,9 +24,11 @@ Dependencies testDependencies({
AuthGateway? authGateway, AuthGateway? authGateway,
StudysetGateway? studysetGateway, StudysetGateway? studysetGateway,
CardGateway? cardGateway, CardGateway? cardGateway,
AboutGateway? aboutGateway,
OpenPerson? openPerson, OpenPerson? openPerson,
}) => Dependencies( }) => Dependencies(
auth: AuthRepository(authGateway ?? FakeAuthGateway()), auth: AuthRepository(authGateway ?? FakeAuthGateway()),
about: AboutRepository(aboutGateway ?? FakeAboutGateway()),
openPerson: openPerson:
openPerson ?? openPerson ??
(person) async { (person) async {