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
26 changed files with 1493 additions and 34 deletions

View file

@ -122,7 +122,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **CI** (`.forgejo/workflows/ci.yaml`) runs `tool/check.sh` on every push, on the Forgejo runner on sahaquiel (`deploy/runner/`), except for a commit that passed before: the job marks passed commits in the runner's home (`checked/`, kept 90 days), so a branch's commit fast-forwarded into `main` goes straight to its deploy. Jobs run directly on the host as the unprivileged user `runner`, never in containers: Docker access would be root on the production server. The runner's systemd unit keeps jobs away from production (no `/home`, no Docker socket, Docker's container networks unreachable, memory and CPU capped); keep it that way when changing it. `runner` has user ID 61000 and must never share one with a container's processes (PostgreSQL and the server run as 999, not remapped): the same user ID can read their environment and files. The job's first step (*Sandbox*) fails if any of this leaks. Flutter is pinned in the workflow (`FLUTTER_VERSION`): move it together with the local one. The server tests there get throwaway passwords from the environment (`SERVERPOD_…`, new for every job), since `passwords.yaml` is never committed; without them they exit with code 1 and no message (Serverpod's own template does the same). CI also runs `serverpod generate` and fails if the committed generated code differs from its output. So never run `dart format` over the server's `lib/` or `test/` as a whole: it reformats the generated code (`lib/src/generated/`, `serverpod_test_tools.dart`); format the files you wrote. CI runs on pushes only: a pull request from a fork would run its code on the production server.
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command the runner's key may run as `deploy` (pinned in `authorized_keys`, from sahaquiel itself only). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy halkyon-learn/deploy/redeploy.sh <commit>`. Never `docker compose down -v` there.
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session, starts a new anonymous account and deletes the old folder. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds connect to learn.halkyon.eu, the project's instance, until the app lets people choose one. They come from a developer's laptop, never from CI: `tool/release.sh` on `main` as pushed and green in CI. Versions follow semantic versioning, worked out from the commits since the last release (`feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): vX.Y.Z`) with a signed tag (a key in `deploy/allowed-signers`), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `snap_store_channel` in the script names a channel. A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `tool/release.sh` on `main` as pushed and green in CI. Versions follow semantic versioning, worked out from the commits since the last release (`feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): vX.Y.Z`) with a signed tag (a key in `deploy/allowed-signers`), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The Snap Store upload is off until `snap_store_channel` in the script names a channel. A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the script releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-features TetheredNet and NonFreeNet declared with their reasons and F-Droid's own icons); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to ramiel as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
## Rules the code relies on
@ -153,6 +153,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`: an address checked with `instance.info()`, or the build's own in one tap), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use.
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card. The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.

View file

@ -113,7 +113,8 @@ Releases are published on [code.halkyon.app](https://code.halkyon.app/project-ha
with the APK and the snap, and the APK also in the project's F-Droid repository
(`https://halkyon.app/fdroid/repo?fingerprint=E8082788756F6B7E682AA22FCEAD24B53E2A8B0912AD66D65E8C2D4F07BB94B0`,
added in the F-Droid app as a repository). The Snap Store (`halkyon-learn`) follows.
Their builds connect to learn.halkyon.eu, the project's instance.
Their builds offer learn.halkyon.eu, the project's instance, at first start; people
may choose another there, and keep accounts on several.
- **Checking a download:** `SHA256SUMS` lists the checksum of every file, signed with the
maintainer's GPG key (`SHA256SUMS.asc`). The APK is signed with the app's key, whose

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),

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