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>
This commit is contained in:
parent
89a3784df4
commit
7621afaa98
23 changed files with 1493 additions and 34 deletions
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
195
halkyon_learn_flutter/lib/src/app/instance_gate.dart
Normal file
195
halkyon_learn_flutter/lib/src/app/instance_gate.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
48
halkyon_learn_flutter/lib/src/core/instance/instance.dart
Normal file
48
halkyon_learn_flutter/lib/src/core/instance/instance.dart
Normal 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';
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
|
|
@ -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.-]'), '_');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
147
halkyon_learn_flutter/test/app/instance_gate_test.dart
Normal file
147
halkyon_learn_flutter/test/app/instance_gate_test.dart
Normal 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]);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue