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:
parent
863f0899e3
commit
38843dbff2
11 changed files with 225 additions and 0 deletions
|
|
@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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`.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
|||
show Client, UuidValue;
|
||||
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/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/serverpod_auth_gateway.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.
|
||||
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`).
|
||||
required final OpenPerson openPerson,
|
||||
final Future<void> Function()? _onDispose,
|
||||
|
|
@ -48,6 +53,7 @@ class Dependencies({
|
|||
final authGateway = ServerpodAuthGateway(client);
|
||||
return Dependencies(
|
||||
auth: AuthRepository(authGateway),
|
||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||
openPerson: (person) => _openPerson(client, person),
|
||||
onDispose: () async {
|
||||
await authGateway.dispose();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.",
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
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/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/session_bloc.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.',
|
||||
),
|
||||
if (!isIdentified) const _GitHubSignIn(),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
showAbout(context, DependenciesScope.of(context).about),
|
||||
child: const Text('About Halkyon Learn'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
|
|
|
|||
66
halkyon_learn_flutter/test/feature/about/about_test.dart
Normal file
66
halkyon_learn_flutter/test/feature/about/about_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
19
halkyon_learn_flutter/test/support/fake_about_gateway.dart
Normal file
19
halkyon_learn_flutter/test/support/fake_about_gateway.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
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/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_repository.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_repository.dart';
|
||||
|
||||
import 'fake_about_gateway.dart';
|
||||
import 'fake_auth_gateway.dart';
|
||||
import 'fake_card_gateway.dart';
|
||||
import 'fake_studyset_gateway.dart';
|
||||
|
|
@ -21,9 +24,11 @@ Dependencies testDependencies({
|
|||
AuthGateway? authGateway,
|
||||
StudysetGateway? studysetGateway,
|
||||
CardGateway? cardGateway,
|
||||
AboutGateway? aboutGateway,
|
||||
OpenPerson? openPerson,
|
||||
}) => Dependencies(
|
||||
auth: AuthRepository(authGateway ?? FakeAuthGateway()),
|
||||
about: AboutRepository(aboutGateway ?? FakeAboutGateway()),
|
||||
openPerson:
|
||||
openPerson ??
|
||||
(person) async {
|
||||
|
|
|
|||
Loading…
Reference in a new issue