feat(instances): choose the instance, and keep accounts on several
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.
As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.
The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.
The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 06:00:47 +02:00
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/app/instance_gate.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/app/person_app.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/settings_screen.dart';
|
|
|
|
|
|
import 'package:material_ui/material_ui.dart';
|
|
|
|
|
|
|
|
|
|
|
|
import '../support/fake_instance_checker.dart';
|
|
|
|
|
|
import '../support/memory_device_settings_store.dart';
|
|
|
|
|
|
import '../support/memory_instance_store.dart';
|
|
|
|
|
|
import '../support/pump_app.dart';
|
|
|
|
|
|
import '../support/test_dependencies.dart';
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
final builtIn = Instance.parse('learn.halkyon.eu')!;
|
|
|
|
|
|
final other = Instance.parse('learn.example.org')!;
|
|
|
|
|
|
late MemoryInstanceStore store;
|
|
|
|
|
|
late List<Instance> opened;
|
|
|
|
|
|
late List<Instance> forgotten;
|
|
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
|
store = MemoryInstanceStore();
|
|
|
|
|
|
opened = [];
|
|
|
|
|
|
forgotten = [];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/// Starts the app on a device that [usedBefore] the choice existed, with
|
|
|
|
|
|
/// [answering] instances answering a check.
|
|
|
|
|
|
Future<InstanceChoice> start(
|
|
|
|
|
|
WidgetTester tester, {
|
|
|
|
|
|
bool usedBefore = false,
|
|
|
|
|
|
Set<Instance> answering = const {},
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
tester.view.physicalSize = const Size(412, 900);
|
|
|
|
|
|
tester.view.devicePixelRatio = 1;
|
|
|
|
|
|
addTearDown(tester.view.reset);
|
|
|
|
|
|
final choice = InstanceChoice(
|
|
|
|
|
|
store,
|
|
|
|
|
|
builtIn: builtIn,
|
|
|
|
|
|
forget: (instance) async => forgotten.add(instance),
|
|
|
|
|
|
);
|
|
|
|
|
|
await choice.load(usedBefore: usedBefore);
|
|
|
|
|
|
await tester.pumpWidget(
|
|
|
|
|
|
InstanceGate(
|
|
|
|
|
|
choice: choice,
|
|
|
|
|
|
checker: FakeInstanceChecker(answering),
|
|
|
|
|
|
deviceSettings: MemoryDeviceSettingsStore(),
|
|
|
|
|
|
open: (instance) async {
|
|
|
|
|
|
opened.add(instance);
|
|
|
|
|
|
return testDependencies();
|
|
|
|
|
|
},
|
|
|
|
|
|
app: SessionGate(app: (person) => PersonApp(person: person)),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
return choice;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Opens Settings › Switch account, further down Settings.
|
|
|
|
|
|
Future<void> openSwitcher(WidgetTester tester) async {
|
|
|
|
|
|
await openTab(tester, 'Profile');
|
|
|
|
|
|
await tester.tap(find.byTooltip('Settings'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
await tester.scrollUntilVisible(
|
|
|
|
|
|
find.text('Switch account'),
|
|
|
|
|
|
200,
|
|
|
|
|
|
// Settings' list; its citation preview is a scroll view of its own.
|
|
|
|
|
|
scrollable: find
|
|
|
|
|
|
.descendant(
|
|
|
|
|
|
of: find.byType(SettingsScreen),
|
|
|
|
|
|
matching: find.byType(Scrollable),
|
|
|
|
|
|
)
|
|
|
|
|
|
.first,
|
|
|
|
|
|
);
|
|
|
|
|
|
// Built, but perhaps still below the window's edge.
|
|
|
|
|
|
await tester.ensureVisible(find.text('Switch account'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
await tester.tap(find.text('Switch account'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
testWidgets('a new install asks for the instance first, and opens the '
|
|
|
|
|
|
"build's in one tap", (tester) async {
|
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
|
2026-10-18 20:33:33 +02:00
|
|
|
|
// The wordmark says it to screen readers.
|
|
|
|
|
|
expect(find.bySemanticsLabel('Welcome to Halkyon Learn'), findsOneWidget);
|
feat(instances): choose the instance, and keep accounts on several
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.
As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.
The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.
The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 06:00:47 +02:00
|
|
|
|
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
|
2026-10-18 20:33:33 +02:00
|
|
|
|
expect(find.byType(TextField), findsNothing, reason: 'no address asked');
|
feat(instances): choose the instance, and keep accounts on several
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.
As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.
The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.
The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 06:00:47 +02:00
|
|
|
|
|
2026-10-18 20:33:33 +02:00
|
|
|
|
await tester.tap(find.text('learn.halkyon.eu'));
|
feat(instances): choose the instance, and keep accounts on several
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.
As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.
The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.
The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 06:00:47 +02:00
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(opened, [builtIn]);
|
|
|
|
|
|
expect(tab('Home'), findsOneWidget);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
testWidgets('another instance is checked before the app moves there', (
|
|
|
|
|
|
tester,
|
|
|
|
|
|
) async {
|
|
|
|
|
|
await start(tester, answering: {other});
|
|
|
|
|
|
|
2026-10-18 20:33:33 +02:00
|
|
|
|
await tester.tap(find.text('Another instance'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
expect(
|
|
|
|
|
|
FocusManager.instance.primaryFocus?.context
|
|
|
|
|
|
?.findAncestorWidgetOfExactType<TextField>(),
|
|
|
|
|
|
isNotNull,
|
|
|
|
|
|
reason: 'the address is typed at once',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
feat(instances): choose the instance, and keep accounts on several
A new install asks first which instance to keep the person's studysets
on, as Mastodon's apps do: an address, checked to answer as an instance
of Halkyon Learn, or the build's own (learn.halkyon.eu) in one tap.
Nothing is made anywhere before that, not even an anonymous account. An
install from before goes on with the build's instance without asking.
As Moshidon does, the device keeps an account on each instance it was
used with, each signed in with its own folder and session, one in use at
a time. Settings › Switch account lists them: another one switches at
once, anonymous accounts included; Add account chooses another instance;
removing one not in use deletes what the device holds of it. Signing out
ends only the account in use.
The build's own instance keeps its data where installs kept it before
(`people/`, Serverpod's own session key), so they keep everything; other
instances get folders and keys of their own. What the device shares
across instances (its settings, sounds, the Pomodoro timer's place in the
system) is made once and outlives every switch.
The F-Droid listing drops TetheredNet: the app is no longer tied to the
project's instance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 06:00:47 +02:00
|
|
|
|
await tester.enterText(find.byType(TextField), 'nowhere.example.org');
|
|
|
|
|
|
await tester.tap(find.text('Continue'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
expect(
|
|
|
|
|
|
find.text(
|
|
|
|
|
|
'No instance of Halkyon Learn answers at nowhere.example.org.',
|
|
|
|
|
|
),
|
|
|
|
|
|
findsOneWidget,
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(opened, isEmpty);
|
|
|
|
|
|
|
|
|
|
|
|
await tester.enterText(find.byType(TextField), 'learn.example.org');
|
|
|
|
|
|
await tester.tap(find.text('Continue'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
expect(opened, [other]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
testWidgets('an install from before goes on without asking', (tester) async {
|
|
|
|
|
|
await start(tester, usedBefore: true);
|
|
|
|
|
|
|
|
|
|
|
|
expect(opened, [builtIn]);
|
|
|
|
|
|
expect(tab('Home'), findsOneWidget);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
testWidgets('Settings switches between the accounts on the device, and '
|
|
|
|
|
|
'removes one not in use', (tester) async {
|
|
|
|
|
|
store = MemoryInstanceStore((current: builtIn, known: [builtIn, other]));
|
|
|
|
|
|
await start(tester);
|
|
|
|
|
|
await openSwitcher(tester);
|
|
|
|
|
|
expect(find.text('learn.halkyon.eu'), findsWidgets);
|
|
|
|
|
|
await tester.tap(find.text('learn.example.org'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
expect(opened, [builtIn, other]);
|
|
|
|
|
|
expect(tab('Home'), findsOneWidget, reason: 'a new app, on Home');
|
|
|
|
|
|
|
|
|
|
|
|
await openSwitcher(tester);
|
|
|
|
|
|
await tester.tap(find.byTooltip('Remove account'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
|
|
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
|
|
|
|
|
|
|
|
expect(forgotten, [builtIn]);
|
|
|
|
|
|
expect(store.kept?.known, [other]);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|