feat(instances): choose the instance, and keep accounts on several
All checks were successful
CI / check (push) Successful in 13m50s
CI / deploy (push) Successful in 1m25s

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-10 06:00:47 +02:00
commit 7621afaa98
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 1493 additions and 34 deletions

View file

@ -437,6 +437,23 @@
"settingsTitle": "Einstellungen",
"settingsAccount": "Konto",
"instanceWelcome": "Willkommen bei Halkyon Learn",
"instanceFirstStartSubtitle": "Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.",
"instanceAddress": "Adresse der Instanz",
"instanceUseBuiltIn": "{name} verwenden",
"instanceContinue": "Weiter",
"instanceAddressRequired": "Gib die Adresse einer Instanz ein.",
"instanceAddressInvalid": "Das ist keine Adresse.",
"instanceUnreachable": "Unter {name} antwortet keine Instanz von Halkyon Learn.",
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
"settingsSwitchAccount": "Konto wechseln",
"accountsTitle": "Konten",
"accountAdd": "Konto hinzufügen",
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
"accountRemoveTooltip": "Konto entfernen",
"accountRemoveTitle": "Konto entfernen?",
"accountRemoveMessage": "Entfernt das Konto auf {name} von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf {name}: Melde dich dort mit GitHub an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.",
"accountRemoveConfirm": "Entfernen",
"settingsSignOut": "Abmelden",
"settingsSignOutTitle": "Abmelden?",
"settingsSignOutAnonymousMessage": "Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden samt Fortschritt gelöscht.",

View file

@ -470,6 +470,27 @@
"settingsTitle": "Settings",
"settingsAccount": "Account",
"instanceWelcome": "Welcome to Halkyon Learn",
"instanceFirstStartSubtitle": "Choose the instance to keep your studysets on. Someone runs each one; you can add others later.",
"instanceAddress": "Instance address",
"instanceUseBuiltIn": "Use {name}",
"@instanceUseBuiltIn": {"placeholders": {"name": {"type": "String"}}},
"instanceContinue": "Continue",
"instanceAddressRequired": "Enter the address of an instance.",
"instanceAddressInvalid": "This isn't an address.",
"instanceUnreachable": "No instance of Halkyon Learn answers at {name}.",
"@instanceUnreachable": {"placeholders": {"name": {"type": "String"}}},
"instanceOpenFailed": "Couldn't open the app on {name}.",
"@instanceOpenFailed": {"placeholders": {"name": {"type": "String"}}},
"settingsSwitchAccount": "Switch account",
"accountsTitle": "Accounts",
"accountAdd": "Add account",
"accountAddSubtitle": "Choose the instance for the account to add.",
"accountRemoveTooltip": "Remove account",
"accountRemoveTitle": "Remove account?",
"accountRemoveMessage": "Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there with GitHub to have it again. An anonymous account can't be signed into again.",
"@accountRemoveMessage": {"placeholders": {"name": {"type": "String"}}},
"accountRemoveConfirm": "Remove",
"settingsSignOut": "Sign out",
"settingsSignOutTitle": "Sign out?",
"settingsSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it with its progress.",

View file

@ -2644,6 +2644,108 @@ abstract class AppLocalizations {
/// **'Account'**
String get settingsAccount;
/// No description provided for @instanceWelcome.
///
/// In en, this message translates to:
/// **'Welcome to Halkyon Learn'**
String get instanceWelcome;
/// No description provided for @instanceFirstStartSubtitle.
///
/// In en, this message translates to:
/// **'Choose the instance to keep your studysets on. Someone runs each one; you can add others later.'**
String get instanceFirstStartSubtitle;
/// No description provided for @instanceAddress.
///
/// In en, this message translates to:
/// **'Instance address'**
String get instanceAddress;
/// No description provided for @instanceUseBuiltIn.
///
/// In en, this message translates to:
/// **'Use {name}'**
String instanceUseBuiltIn(String name);
/// No description provided for @instanceContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get instanceContinue;
/// No description provided for @instanceAddressRequired.
///
/// In en, this message translates to:
/// **'Enter the address of an instance.'**
String get instanceAddressRequired;
/// No description provided for @instanceAddressInvalid.
///
/// In en, this message translates to:
/// **'This isn\'t an address.'**
String get instanceAddressInvalid;
/// No description provided for @instanceUnreachable.
///
/// In en, this message translates to:
/// **'No instance of Halkyon Learn answers at {name}.'**
String instanceUnreachable(String name);
/// No description provided for @instanceOpenFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t open the app on {name}.'**
String instanceOpenFailed(String name);
/// No description provided for @settingsSwitchAccount.
///
/// In en, this message translates to:
/// **'Switch account'**
String get settingsSwitchAccount;
/// No description provided for @accountsTitle.
///
/// In en, this message translates to:
/// **'Accounts'**
String get accountsTitle;
/// No description provided for @accountAdd.
///
/// In en, this message translates to:
/// **'Add account'**
String get accountAdd;
/// No description provided for @accountAddSubtitle.
///
/// In en, this message translates to:
/// **'Choose the instance for the account to add.'**
String get accountAddSubtitle;
/// No description provided for @accountRemoveTooltip.
///
/// In en, this message translates to:
/// **'Remove account'**
String get accountRemoveTooltip;
/// No description provided for @accountRemoveTitle.
///
/// In en, this message translates to:
/// **'Remove account?'**
String get accountRemoveTitle;
/// No description provided for @accountRemoveMessage.
///
/// In en, this message translates to:
/// **'Removes the account on {name} from this device, with all the device holds of it. What it synced stays on {name}: sign in there with GitHub to have it again. An anonymous account can\'t be signed into again.'**
String accountRemoveMessage(String name);
/// No description provided for @accountRemoveConfirm.
///
/// In en, this message translates to:
/// **'Remove'**
String get accountRemoveConfirm;
/// No description provided for @settingsSignOut.
///
/// In en, this message translates to:

View file

@ -1547,6 +1547,67 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsAccount => 'Konto';
@override
String get instanceWelcome => 'Willkommen bei Halkyon Learn';
@override
String get instanceFirstStartSubtitle =>
'Wähle die Instanz, auf der deine Lernsets liegen sollen. Jede betreibt jemand; weitere kannst du später hinzufügen.';
@override
String get instanceAddress => 'Adresse der Instanz';
@override
String instanceUseBuiltIn(String name) {
return '$name verwenden';
}
@override
String get instanceContinue => 'Weiter';
@override
String get instanceAddressRequired => 'Gib die Adresse einer Instanz ein.';
@override
String get instanceAddressInvalid => 'Das ist keine Adresse.';
@override
String instanceUnreachable(String name) {
return 'Unter $name antwortet keine Instanz von Halkyon Learn.';
}
@override
String instanceOpenFailed(String name) {
return 'Die App auf $name konnte nicht geöffnet werden.';
}
@override
String get settingsSwitchAccount => 'Konto wechseln';
@override
String get accountsTitle => 'Konten';
@override
String get accountAdd => 'Konto hinzufügen';
@override
String get accountAddSubtitle =>
'Wähle die Instanz für das Konto, das du hinzufügst.';
@override
String get accountRemoveTooltip => 'Konto entfernen';
@override
String get accountRemoveTitle => 'Konto entfernen?';
@override
String accountRemoveMessage(String name) {
return 'Entfernt das Konto auf $name von diesem Gerät, mit allem, was das Gerät davon hat. Was es synchronisiert hat, bleibt auf $name: Melde dich dort mit GitHub an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.';
}
@override
String get accountRemoveConfirm => 'Entfernen';
@override
String get settingsSignOut => 'Abmelden';

View file

@ -1537,6 +1537,67 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsAccount => 'Account';
@override
String get instanceWelcome => 'Welcome to Halkyon Learn';
@override
String get instanceFirstStartSubtitle =>
'Choose the instance to keep your studysets on. Someone runs each one; you can add others later.';
@override
String get instanceAddress => 'Instance address';
@override
String instanceUseBuiltIn(String name) {
return 'Use $name';
}
@override
String get instanceContinue => 'Continue';
@override
String get instanceAddressRequired => 'Enter the address of an instance.';
@override
String get instanceAddressInvalid => 'This isn\'t an address.';
@override
String instanceUnreachable(String name) {
return 'No instance of Halkyon Learn answers at $name.';
}
@override
String instanceOpenFailed(String name) {
return 'Couldn\'t open the app on $name.';
}
@override
String get settingsSwitchAccount => 'Switch account';
@override
String get accountsTitle => 'Accounts';
@override
String get accountAdd => 'Add account';
@override
String get accountAddSubtitle =>
'Choose the instance for the account to add.';
@override
String get accountRemoveTooltip => 'Remove account';
@override
String get accountRemoveTitle => 'Remove account?';
@override
String accountRemoveMessage(String name) {
return 'Removes the account on $name from this device, with all the device holds of it. What it synced stays on $name: sign in there with GitHub to have it again. An anonymous account can\'t be signed into again.';
}
@override
String get accountRemoveConfirm => 'Remove';
@override
String get settingsSignOut => 'Sign out';

View file

@ -1,23 +1,44 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.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/bloc/app_bloc_observer.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_store.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/data/kanjivg_license.dart';
import 'package:material_ui/material_ui.dart';
import 'package:path/path.dart' as p;
import 'package:serverpod_flutter/serverpod_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Bloc.observer = const AppBlocObserver();
registerAppFontLicenses();
registerKanjiVgLicense();
final dependencies = await Dependencies.create();
// The instance the build names (`--dart-define=SERVER_URL=…`, or
// `assets/config.json`): the first offered, and the one installs from
// before the choice go on with.
final builtIn = Instance.parse(await getServerUrl())!;
final device = await DeviceParts.create(builtIn: builtIn);
final instances = InstanceChoice(
FileInstanceStore(File(p.join(device.support, 'instances.json'))),
builtIn: builtIn,
forget: device.storage.forget,
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
DependenciesScope(
dependencies: dependencies,
child: SessionGate(app: (person) => PersonApp(person: person)),
InstanceGate(
choice: instances,
checker: ServerpodInstanceChecker(),
open: (instance) => Dependencies.create(instance, device),
deviceSettings: device.deviceSettingsStore,
app: SessionGate(app: (person) => PersonApp(person: person)),
),
);
}

View file

@ -0,0 +1,195 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/device_settings/widget/device_settings_scope.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_theme.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/first_start_screen.dart';
import 'package:material_ui/material_ui.dart';
/// The app on the instance in use ([InstanceChoice]): on a new install
/// first the choice of instance, then [app] on its dependencies, opened by
/// [open] anew whenever the person switches, the previous ones closed first.
///
/// [app] is built anew for every instance (keyed by it), so nothing of one
/// instance's app reaches another's.
class const InstanceGate({
required final InstanceChoice choice,
/// Checks an address before the app moves there.
required final InstanceChecker checker,
/// The dependencies of the app on an instance.
required final Future<Dependencies> Function(Instance instance) open,
/// The device's settings, for the look and language while no instance's
/// app shows.
required final DeviceSettingsStore deviceSettings,
/// The app on the instance in use.
required final Widget app,
super.key,
}) extends StatefulWidget {
@override
State<InstanceGate> createState() => _InstanceGateState();
}
class _InstanceGateState() extends State<InstanceGate> {
Instance? _opened;
Dependencies? _dependencies;
var _failed = false;
var _look = const DeviceSettings();
/// The last switch, which the next one waits for.
var _switching = Future<void>.value();
@override
void initState() {
super.initState();
widget.choice.addListener(_onChoice);
unawaited(_readLook());
_onChoice();
}
@override
void dispose() {
widget.choice.removeListener(_onChoice);
unawaited(_dependencies?.dispose());
super.dispose();
}
Future<void> _readLook() async {
final look = await widget.deviceSettings.read();
if (mounted) {
setState(() => _look = look);
}
}
void _onChoice() {
final current = widget.choice.current;
if (current != _opened) {
_switching = _switching.then((_) => _open(current));
}
}
/// Closes what is open, then opens [instance]'s app; nothing when it is
/// open already, unless [again], after a failure.
Future<void> _open(Instance? instance, {bool again = false}) async {
if (!mounted || (instance == _opened && !again)) {
return;
}
final previous = _dependencies;
setState(() {
_opened = instance;
_dependencies = null;
_failed = false;
});
await previous?.dispose();
if (instance == null) {
return;
}
await _readLook();
try {
final dependencies = await widget.open(instance);
if (!mounted || _opened != instance) {
await dependencies.dispose();
return;
}
setState(() => _dependencies = dependencies);
} on Object catch (error, st) {
log(
'Opening $instance failed',
name: 'Instances',
error: error,
stackTrace: st,
);
if (mounted && _opened == instance) {
setState(() => _failed = true);
}
}
}
@override
Widget build(BuildContext context) {
final current = widget.choice.current;
final dependencies = _dependencies;
return InstanceScope(
notifier: widget.choice,
checker: widget.checker,
child: switch (current) {
null => _PlainApp(look: _look, home: const FirstStartScreen()),
final instance when dependencies != null && _opened == instance =>
DependenciesScope(
key: ValueKey(instance),
dependencies: dependencies,
child: widget.app,
),
final instance when _failed && _opened == instance => _PlainApp(
look: _look,
home: _OpenFailed(
instance: instance,
onRetry: () => _switching = _switching.then(
(_) => _open(instance, again: true),
),
),
),
// Opening, or closing the one before.
_ => _PlainApp(
look: _look,
home: const Scaffold(body: Center(child: AppLoadingIndicator())),
),
},
);
}
}
/// The app's look around a single screen, while no instance's app shows.
class const _PlainApp({
required final DeviceSettings look,
required final Widget home,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
theme: appTheme(Brightness.light),
darkTheme: appTheme(Brightness.dark),
themeMode: look.themeMode,
locale: look.locale,
localeListResolutionCallback: resolveAppLocale,
localizationsDelegates: appLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: home,
);
}
class const _OpenFailed({
required final Instance instance,
required final VoidCallback onRetry,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.instanceOpenFailed(instance.name)),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: Text(l10n.commonTryAgain)),
],
),
),
);
}
}

View file

@ -14,6 +14,8 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_setti
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
import 'package:halkyon_learn_flutter/src/core/files/file_saver.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_storage.dart';
import 'package:halkyon_learn_flutter/src/core/sync/app_compatibility.dart';
import 'package:halkyon_learn_flutter/src/core/sync/pending_changes_store.dart';
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
@ -142,19 +144,22 @@ class Dependencies({
required final PomodoroPlatform pomodoro,
final Future<void> Function()? _onDispose,
}) {
/// Builds the production graph around one Serverpod client.
///
/// The server URL comes from `--dart-define=SERVER_URL=…` (or
/// `assets/config.json`); the client holds no secrets.
static Future<Dependencies> create() async {
final client = Client(await getServerUrl())
/// Builds the production graph on [instance], around one Serverpod
/// client of its own, with what [device] shares across instances. Its
/// session and its people's folders are the instance's own
/// (`InstanceStorage`); the client holds no secrets.
static Future<Dependencies> create(
Instance instance,
DeviceParts device,
) async {
final client = Client('${instance.url}')
..connectivityMonitor = FlutterConnectivityMonitor()
..authSessionManager = FlutterAuthSessionManager();
final package = await PackageInfo.fromPlatform();
final appVersion = AppVersion(
version: package.version,
build: package.buildNumber,
);
..authSessionManager = FlutterAuthSessionManager(
storage: SecureClientAuthSuccessStorage(
authSuccessStorageKey: device.storage.sessionKeyOf(instance),
),
);
final appVersion = device.appVersion;
// Whether the server still works with this build: asked before the
// first sync of each launch.
final compatibility = AppCompatibility(
@ -167,35 +172,30 @@ class Dependencies({
await client.auth.initialize();
final authGateway = ServerpodAuthGateway(client);
final support = (await getApplicationSupportDirectory()).path;
final folders = PersonFolders(Directory(p.join(support, 'people')));
final deviceSettingsStore = FileDeviceSettingsStore(
File(p.join(support, 'device_settings.json')),
);
final folders = PersonFolders(device.storage.peopleOf(instance));
// One person at a time: before the next person's data opens, the previous
// person's database has closed, so their folder can go. If it doesn't
// close, the app goes on after a while and the folder goes next time.
var previousClosed = Future<void>.value();
await PdfrxEngine.initialize();
final sounds = AudioplayersSoundPlayer();
return Dependencies(
serverName: Uri.parse(client.host).host,
serverName: instance.name,
auth: AuthRepository(authGateway),
about: AboutRepository(ServerpodAboutGateway(client)),
dictionaries: DictionaryRepository(ServerpodDictionaryGateway(client)),
maps: GeoMapRepository(),
strokes: StrokeRepository(),
sounds: sounds,
sounds: device.sounds,
files: FileSelectorFilePicker(),
fileSaver: PlatformFileSaver(),
videos: IframeYoutubeVideos(),
pdfEngine: PdfrxEngine(),
ankiPackages: IsolateAnkiPackageReader(),
exportArchiver: IsolateExportArchiver(),
deviceSettingsStore: deviceSettingsStore,
deviceSettings: await deviceSettingsStore.read(),
deviceSettingsStore: device.deviceSettingsStore,
// Read anew for each instance: they may have changed on another.
deviceSettings: await device.deviceSettingsStore.read(),
appVersion: appVersion,
pomodoro: pomodoroPlatform(support),
pomodoro: device.pomodoro,
openPerson: (person) async {
await previousClosed.timeout(
const Duration(seconds: 10),
@ -217,8 +217,8 @@ class Dependencies({
rethrow;
}
},
// The device's parts stay, for the next instance.
onDispose: () async {
await sounds.dispose();
await authGateway.dispose();
client.close();
},
@ -231,6 +231,48 @@ class Dependencies({
}
}
/// What the dependencies on every instance share: the device's own parts,
/// made once when the app starts, outliving every switch of instance.
class DeviceParts({
/// The app's support folder, where the device keeps its files.
required final String support,
/// Where the device keeps what it holds of the account on each instance.
required final InstanceStorage storage,
/// Which release of the app this is.
required final AppVersion appVersion,
/// Plays the sounds in content.
required final SoundPlayer sounds,
/// What the system shows of the Pomodoro timer, and its alerts.
required final PomodoroPlatform pomodoro,
/// Keeps the device's own settings, such as its appearance.
required final DeviceSettingsStore deviceSettingsStore,
}) {
/// Makes them, [builtIn] being the instance the build names.
static Future<DeviceParts> create({required Instance builtIn}) async {
final package = await PackageInfo.fromPlatform();
final support = (await getApplicationSupportDirectory()).path;
await PdfrxEngine.initialize();
return DeviceParts(
support: support,
storage: InstanceStorage(support, builtIn: builtIn),
appVersion: AppVersion(
version: package.version,
build: package.buildNumber,
),
sounds: AudioplayersSoundPlayer(),
pomodoro: pomodoroPlatform(support),
deviceSettingsStore: FileDeviceSettingsStore(
File(p.join(support, 'device_settings.json')),
),
);
}
}
/// Opens [person]'s own local database in [folder] (see [PersonFolders]), its
/// sync, and the repositories; [onClosed] runs once all is closed again.
Future<PersonDependencies> _openPerson(

View file

@ -0,0 +1,48 @@
import 'package:equatable/equatable.dart';
/// An instance of Halkyon Learn: a server people keep their studysets on,
/// by its address (`https://learn.halkyon.eu/`).
final class const Instance._(
/// Its address: HTTPS unless the person said otherwise, with a path
/// ending in `/`, and nothing after it.
final Uri url,
) with Equatable {
/// The instance at [address] as a person types it or a build names it:
/// `learn.example.org`, with `https://` or without, with a port or a path
/// or not; null for an address that can't be one.
static Instance? parse(String address) {
var text = address.trim();
if (text.isEmpty || text.contains(RegExp(r'\s'))) {
return null;
}
if (!text.contains('://')) {
text = 'https://$text';
}
final url = Uri.tryParse(text);
if (url == null ||
!(url.isScheme('https') || url.isScheme('http')) ||
url.host.isEmpty ||
url.userInfo.isNotEmpty) {
return null;
}
final path = url.path.endsWith('/') ? url.path : '${url.path}/';
return Instance._(
Uri(
scheme: url.scheme,
host: url.host.toLowerCase(),
port: url.hasPort ? url.port : null,
path: path,
),
);
}
/// How people know it: its host, with the port if it has one
/// (`learn.halkyon.eu`, `localhost:8080`).
String get name => url.hasPort ? '${url.host}:${url.port}' : url.host;
@override
List<Object?> get props => [url];
@override
String toString() => '$url';
}

View file

@ -0,0 +1,26 @@
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
/// Whether an instance of Halkyon Learn answers at an address, before the
/// app moves there. Tests replace it with a fake.
abstract interface class InstanceChecker() {
/// Whether [instance] answers as an instance of Halkyon Learn.
Future<bool> answers(Instance instance);
}
/// Asks the instance about itself (`instance.info()`), with a client of its
/// own that holds no session.
class ServerpodInstanceChecker() implements InstanceChecker {
@override
Future<bool> answers(Instance instance) async {
final client = Client('${instance.url}');
try {
await client.instance.info().timeout(const Duration(seconds: 15));
return true;
} on Object {
return false;
} finally {
client.close();
}
}
}

View file

@ -0,0 +1,72 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_store.dart';
/// The instances this device keeps an account on, and the one the app uses
/// (decided 2026-10-10, as Moshidon does): each account stays signed in,
/// with its own folder and session, one in use at a time. Switching back is
/// instant, anonymous accounts included; signing out ends only the one in
/// use, and removing one from the device forgets what it held of it.
class InstanceChoice(
final InstanceStore _store, {
/// The instance the build names: the one installs used before there was
/// a choice, offered first to new ones.
required final Instance builtIn,
/// Deletes what the device holds of the account on an instance: its
/// people's folder and its session.
required final Future<void> Function(Instance instance) forget,
}) extends ChangeNotifier {
Instance? _current;
var _known = const <Instance>[];
/// The instance in use; null until one is chosen.
Instance? get current => _current;
/// Those the device keeps an account on, in the order they were added.
List<Instance> get known => _known;
/// Reads what was chosen. An install from before there was a choice
/// ([usedBefore]: it has an account already) goes on with [builtIn]
/// without asking.
Future<void> load({required bool usedBefore}) async {
switch (await _store.read()) {
case (:final current, :final known):
_current = current;
_known = known;
case null when usedBefore:
await use(builtIn);
return;
case null:
break;
}
notifyListeners();
}
/// Uses [instance], keeping its account on the device from now on.
Future<void> use(Instance instance) async {
_current = instance;
if (!_known.contains(instance)) {
_known = [..._known, instance];
}
await _store.write((current: instance, known: _known));
notifyListeners();
}
/// Forgets the account on [instance], one not in use: what the device
/// holds of it goes. What it synced stays on the instance.
Future<void> remove(Instance instance) async {
final current = _current;
if (instance == current || current == null) {
return;
}
await forget(instance);
_known = [
for (final each in _known)
if (each != instance) each,
];
await _store.write((current: current, known: _known));
notifyListeners();
}
}

View file

@ -0,0 +1,22 @@
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
import 'package:material_ui/material_ui.dart';
/// Hands the instances the device keeps accounts on ([InstanceChoice]) to
/// the app, which rebuilds where it depends on them when they change.
class const InstanceScope({
required super.notifier,
/// Checks an address before the app moves there.
required final InstanceChecker checker,
required super.child,
super.key,
}) extends InheritedNotifier<InstanceChoice> {
/// The scope above [context]; null where there is none, as in a test of a
/// single screen.
static InstanceScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<InstanceScope>();
/// The instances.
InstanceChoice get choice => notifier!;
}

View file

@ -0,0 +1,60 @@
import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:path/path.dart' as p;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// Where the device keeps what it holds of the account on each instance, in
/// the app's support folder [_support]. The instance the build names keeps
/// it where installs did before there was a choice: `people/`, and the
/// session under Serverpod's own key. Every other has a folder of its own,
/// `instances/<name>/`, and a key of its own.
class InstanceStorage(
final String _support, {
/// The instance the build names.
required final Instance builtIn,
}) {
/// Whether the device had an account before there was a choice: one on
/// [builtIn].
bool usedBefore() => _peopleOfBuiltIn.existsSync();
/// The folder of the people of [instance] (see `PersonFolders`).
Directory peopleOf(Instance instance) => instance == builtIn
? _peopleOfBuiltIn
: Directory(p.join(_folderOf(instance).path, 'people'));
/// The key [instance]'s session is kept under; null for Serverpod's own.
String? sessionKeyOf(Instance instance) => instance == builtIn
? null
: 'serverpod_auth_success_key@${_nameOf(instance)}';
/// Deletes what the device holds of the account on [instance]: its
/// people's folder and its session. The session itself stays valid on the
/// instance until it expires.
Future<void> forget(Instance instance) async {
final folder = instance == builtIn ? _peopleOfBuiltIn : _folderOf(instance);
try {
await folder.delete(recursive: true);
} on PathNotFoundException {
// Nothing was kept.
}
await SecureClientAuthSuccessStorage(
authSuccessStorageKey: sessionKeyOf(instance),
).set(null);
}
Directory get _peopleOfBuiltIn => Directory(p.join(_support, 'people'));
Directory _folderOf(Instance instance) =>
Directory(p.join(_support, 'instances', _nameOf(instance)));
/// [instance]'s address as a folder name: `learn.example.org`,
/// `localhost_8080`, `example.org_learn` for one under a path.
static String _nameOf(Instance instance) {
final path = instance.url.path;
return '${instance.name}${path == '/' ? '' : path}'
.replaceAll(RegExp(r'/+$'), '')
.replaceAll(RegExp('[^a-z0-9.-]'), '_');
}
}

View file

@ -0,0 +1,72 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
/// The instances the device keeps an account on, in the order they were
/// added, and the one in use.
typedef InstanceList = ({Instance current, List<Instance> known});
/// Where the device keeps which instances it has accounts on.
abstract interface class InstanceStore() {
/// The instances kept; null when none was chosen yet, or the list can't
/// be read.
Future<InstanceList?> read();
/// Keeps [instances].
Future<void> write(InstanceList instances);
}
/// Keeps them as JSON in [_file], beside the people's folders: they are the
/// device's, not a person's. Written to a temporary file first and renamed
/// into place.
class FileInstanceStore(final File _file) implements InstanceStore {
// One write at a time: they share the temporary file.
final _queue = SerialQueue();
@override
Future<InstanceList?> read() async {
try {
if (jsonDecode(await _file.readAsString()) case {
'current': final String current,
'known': final List<Object?> known,
}) {
final instances = [
for (final address in known.whereType<String>())
?Instance.parse(address),
];
if (Instance.parse(current) case final current?) {
return (
current: current,
known: [
...instances,
if (!instances.contains(current)) current,
],
);
}
}
return null;
} on FileSystemException {
return null;
} on FormatException catch (error) {
log('The instances are unreadable', name: 'Instances', error: error);
return null;
}
}
@override
Future<void> write(InstanceList instances) => _queue.run(() async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.new');
await temporary.writeAsString(
jsonEncode({
'current': '${instances.current}',
'known': [for (final instance in instances.known) '$instance'],
}),
flush: true,
);
await temporary.rename(_file.path);
});
}

View file

@ -0,0 +1,134 @@
import 'dart:async';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_step.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// The accounts on this device, one per instance, the one in use marked, as
/// Mastodon's apps list them: tapping another switches to it, its trailing
/// button removes it from the device, and Add account adds one on another
/// instance.
Future<void> showAccountSwitcher(BuildContext context) => showAdaptiveSheet(
context,
builder: (sheetContext) => const _AccountSwitcherSheet(),
);
class const _AccountSwitcherSheet() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final choice = InstanceScope.maybeOf(context)!.choice;
return SheetScaffold(
title: l10n.accountsTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final instance in choice.known)
_AccountTile(
instance: instance,
inUse: instance == choice.current,
),
const Divider(height: 1),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.plus),
title: Text(l10n.accountAdd),
onTap: () {
Navigator.of(context)
..pop()
..push(
MaterialPageRoute<void>(
builder: (routeContext) => const AddAccountScreen(),
),
);
},
),
],
),
);
}
}
class const _AccountTile({
required final Instance instance,
required final bool inUse,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final choice = InstanceScope.maybeOf(context)!.choice;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(instance.name.characters.first.toUpperCase()),
),
title: Text(instance.name),
trailing: inUse
? Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary)
: IconButton(
icon: const Icon(LucideIcons.x),
tooltip: l10n.accountRemoveTooltip,
onPressed: () => _remove(context, choice),
),
// The sheet goes first, so the app rebuilding on the other instance
// doesn't meet an open route.
onTap: inUse
? null
: () {
Navigator.of(context).pop();
unawaited(choice.use(instance));
},
);
}
Future<void> _remove(BuildContext context, InstanceChoice choice) async {
final l10n = AppLocalizations.of(context);
final navigator = Navigator.of(context);
if (await confirmDestructiveAction(
context,
title: l10n.accountRemoveTitle,
message: l10n.accountRemoveMessage(instance.name),
confirmLabel: l10n.accountRemoveConfirm,
)) {
navigator.pop();
await choice.remove(instance);
}
}
}
/// Adds an account on another instance: the instance chosen, the app moves
/// there and starts an anonymous account, as on a first start, unless the
/// device has one there already.
class const AddAccountScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scope = InstanceScope.maybeOf(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.accountAdd)),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: InstanceStep(
builtIn: scope.choice.builtIn,
checker: scope.checker,
subtitle: l10n.accountAddSubtitle,
// The app rebuilds on the instance, without this screen.
onChosen: scope.choice.use,
),
),
),
),
);
}
}

View file

@ -0,0 +1,46 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_step.dart';
import 'package:material_ui/material_ui.dart';
/// The first screen of a new install: which instance to keep the person's
/// studysets on. Nothing is made anywhere until they choose, not even an
/// anonymous account.
class const FirstStartScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final scope = InstanceScope.maybeOf(context)!;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.instanceWelcome,
style: theme.textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
InstanceStep(
builtIn: scope.choice.builtIn,
checker: scope.checker,
subtitle: l10n.instanceFirstStartSubtitle,
onChosen: scope.choice.use,
),
],
),
),
),
),
),
);
}
}

View file

@ -0,0 +1,133 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
import 'package:material_ui/material_ui.dart';
/// Choosing an instance, as Mastodon's apps do: its address typed, checked
/// to answer as an instance of Halkyon Learn, or the one the build names in
/// one tap. [onChosen] gets the instance.
class const InstanceStep({
/// The instance the build names, offered in one tap.
required final Instance builtIn,
/// Checks a typed address.
required final InstanceChecker checker,
/// What happens with the instance chosen.
required final ValueChanged<Instance> onChosen,
/// A line above the address: what the choice is for.
final String? subtitle,
super.key,
}) extends StatefulWidget {
@override
State<InstanceStep> createState() => _InstanceStepState();
}
class _InstanceStepState() extends State<InstanceStep> {
final _address = TextEditingController();
var _checking = false;
String? _error;
@override
void dispose() {
_address.dispose();
super.dispose();
}
Future<void> _continue() async {
final l10n = AppLocalizations.of(context);
final text = _address.text.trim();
if (text.isEmpty) {
setState(() => _error = l10n.instanceAddressRequired);
return;
}
final instance = Instance.parse(text);
if (instance == null) {
setState(() => _error = l10n.instanceAddressInvalid);
return;
}
// The build's own needs no asking.
if (instance == widget.builtIn) {
widget.onChosen(instance);
return;
}
setState(() {
_checking = true;
_error = null;
});
final answers = await widget.checker.answers(instance);
if (!mounted) {
return;
}
setState(() {
_checking = false;
if (!answers) {
_error = l10n.instanceUnreachable(instance.name);
}
});
if (answers) {
widget.onChosen(instance);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final subtitle = widget.subtitle;
final error = _error;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (subtitle != null) ...[
Text(
subtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 24),
],
TextField(
controller: _address,
enabled: !_checking,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
decoration: InputDecoration(
labelText: l10n.instanceAddress,
hintText: 'learn.example.org',
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _checking ? null : _continue(),
),
const SizedBox(height: 8),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton(
onPressed: _checking ? null : () => widget.onChosen(widget.builtIn),
child: Text(l10n.instanceUseBuiltIn(widget.builtIn.name)),
),
),
if (error != null) ...[
const SizedBox(height: 8),
Text(error, style: TextStyle(color: theme.colorScheme.error)),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _checking ? null : _continue,
child: _checking
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.instanceContinue),
),
],
);
}
}

View file

@ -3,12 +3,14 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.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/device_settings/bloc/device_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_status_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/about/widget/about.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/instance/widget/account_switcher_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/bloc/privacy_settings_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/widget/citation_settings_card.dart';
@ -58,6 +60,14 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
style: theme.textTheme.titleMedium,
),
),
// The accounts on this device, one per instance.
if (InstanceScope.maybeOf(context) case final scope?)
ListTile(
leading: const Icon(LucideIcons.users),
title: Text(l10n.settingsSwitchAccount),
subtitle: Text(scope.choice.current?.name ?? ''),
onTap: () => showAccountSwitcher(context),
),
ListTile(
leading: const Icon(LucideIcons.logOut),
title: Text(l10n.settingsSignOut),

View file

@ -0,0 +1,147 @@
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);
expect(find.text('Welcome to Halkyon Learn'), findsOneWidget);
expect(opened, isEmpty, reason: 'nothing made anywhere yet');
await tester.tap(find.text('Use learn.halkyon.eu'));
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});
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]);
});
}

View file

@ -0,0 +1,88 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
import '../../support/memory_instance_store.dart';
void main() {
final builtIn = Instance.parse('learn.halkyon.eu')!;
final other = Instance.parse('learn.example.org')!;
late MemoryInstanceStore store;
late List<Instance> forgotten;
InstanceChoice choice() => InstanceChoice(
store,
builtIn: builtIn,
forget: (instance) async {
forgotten.add(instance);
},
);
setUp(() {
store = MemoryInstanceStore();
forgotten = [];
});
test('a new install has no instance until one is chosen', () async {
final instances = choice();
await instances.load(usedBefore: false);
expect(instances.current, isNull);
expect(instances.known, isEmpty);
await instances.use(other);
expect(instances.current, other);
expect(store.kept?.current, other);
expect(store.kept?.known, [other]);
});
test('an install from before there was a choice goes on with the '
"build's instance", () async {
final instances = choice();
await instances.load(usedBefore: true);
expect(instances.current, builtIn);
expect(store.kept?.current, builtIn);
expect(store.kept?.known, [builtIn]);
});
test('what was chosen is read back', () async {
store = MemoryInstanceStore((current: other, known: [builtIn, other]));
final instances = choice();
await instances.load(usedBefore: true);
expect(instances.current, other);
expect(instances.known, [builtIn, other]);
});
test('switching keeps every account; removing one forgets what the '
'device holds of it, never of the one in use', () async {
final instances = choice();
await instances.load(usedBefore: true);
await instances.use(other);
await instances.use(builtIn);
expect(instances.known, [builtIn, other]);
await instances.remove(builtIn);
expect(forgotten, isEmpty, reason: 'in use');
await instances.remove(other);
expect(forgotten, [other]);
expect(instances.known, [builtIn]);
expect(store.kept?.current, builtIn);
expect(store.kept?.known, [builtIn]);
});
test('tells its listeners of every change', () async {
final instances = choice();
var changes = 0;
instances.addListener(() => changes++);
await instances.load(usedBefore: false);
await instances.use(other);
await instances.use(builtIn);
await instances.remove(other);
expect(changes, 4);
});
}

Some files were not shown because too many files have changed in this diff Show more