halkyon_app: instances, sign-in and sync plumbing for Halkyon's apps

What every Halkyon app's shell needs, taken from Halkyon Learn's and set
on halkyon_base's client: the instances a device keeps an account on,
the first start's tiles and where an instance keeps people's data, the
account switcher; signing in anonymously, with GitHub or an email code,
switching person and the start screen; the gates that build an app's
dependencies per instance and per person; and the sync service with its
status, Settings card and banner. Texts of its own in English and
German take the app's name; what only the app can say, it passes in.
Fakes for apps' tests are in testing.dart.

An instance's basics now name the app it serves (`app`, set by the
host's configuration), and an app accepts only an instance of its own.
SerialQueue and CoalescingRunner move into halkyon_core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-19 15:18:49 +02:00
commit e23cc77f7c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
64 changed files with 10761 additions and 10 deletions

View file

@ -0,0 +1,70 @@
/// What every Halkyon app is built around, on an instance's halkyon_base:
///
/// - **Instances**: the ones a device keeps an account on, one in use at a
/// time (`InstanceChoice`), chosen at first start (`FirstStartScreen`) or
/// added later (`showAccountSwitcher`), each checked before the app moves
/// there (`InstanceChecker`) and saying where it keeps people's data.
/// - **Gates**: `InstanceGate` opens the app's dependencies on the instance
/// in use (`AppDependencies`, `AppScope`); `SessionGate` keeps a person
/// signed in and asks how to start once signed out (`StartScreen`);
/// `PersonScope` opens each person's own dependencies.
/// - **Signing in**: anonymously, with GitHub or with a code by mail
/// (`AuthRepository` on `ServerpodAuthGateway`), and the blocs and sheet
/// that switch a device to another account.
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
/// `SyncTroubleBadge`).
///
/// An app gives its name, look and own words as an `AppFrame`, and installs
/// `HalkyonAppLocalizations.delegate` beside its own.
library;
export 'l10n/halkyon_app_localizations.dart';
export 'src/app/app_frame.dart' show AppFrame, AppFrameScope, LookSettings;
export 'src/app/app_scope.dart';
export 'src/app/instance_gate.dart';
export 'src/app/person_scope.dart';
export 'src/auth/bloc/email_sign_in_bloc.dart';
export 'src/auth/bloc/github_sign_in_bloc.dart';
export 'src/auth/bloc/session_bloc.dart';
export 'src/auth/bloc/sign_in_ways_cubit.dart';
export 'src/auth/data/auth_gateway.dart';
export 'src/auth/data/auth_repository.dart';
export 'src/auth/data/loopback_web_auth.dart';
export 'src/auth/data/serverpod_auth_gateway.dart';
export 'src/auth/data/sign_in_addresses.dart';
export 'src/auth/data/signed_out_mark.dart';
export 'src/auth/model/auth_status.dart';
export 'src/auth/model/email_sign_in.dart';
export 'src/auth/model/github_link_outcome.dart';
export 'src/auth/model/github_sign_in.dart';
export 'src/auth/model/sign_in_ways.dart';
export 'src/auth/widget/email_sign_in_sheet.dart';
export 'src/auth/widget/session_gate.dart';
export 'src/auth/widget/start_screen.dart';
export 'src/base/base_client.dart';
export 'src/instance/country_names.dart';
export 'src/instance/flag_icons_license.dart';
export 'src/instance/instance.dart';
export 'src/instance/instance_checker.dart';
export 'src/instance/instance_choice.dart';
export 'src/instance/instance_locations.dart';
export 'src/instance/instance_scope.dart';
export 'src/instance/instance_storage.dart';
export 'src/instance/instance_store.dart';
export 'src/instance/widget/account_switcher_sheet.dart';
export 'src/instance/widget/first_start_screen.dart';
export 'src/instance/widget/instance_location.dart';
export 'src/instance/widget/instance_location_words.dart';
export 'src/instance/widget/instance_step.dart';
export 'src/instance/widget/instance_tile.dart';
export 'src/person/person_folders.dart';
export 'src/sync/app_compatibility.dart';
export 'src/sync/bloc/sync_status_bloc.dart';
export 'src/sync/pending_changes_store.dart';
export 'src/sync/sync_service.dart';
export 'src/sync/sync_status.dart';
export 'src/sync/widget/app_mismatch_banner.dart';
export 'src/sync/widget/sync_and_wait.dart';
export 'src/sync/widget/sync_status_card.dart';
export 'src/sync/widget/sync_trouble_badge.dart';

View file

@ -0,0 +1,78 @@
{
"@@locale": "de",
"tryAgain": "Erneut versuchen",
"instanceWelcome": "Willkommen bei {app}",
"instanceBuiltInTagline": "In der App voreingestellt",
"instanceAnother": "Andere Instanz",
"instanceAnotherTagline": "Gib ihre Adresse ein",
"instanceAddress": "Adresse der Instanz",
"instanceAddressInvalid": "Das ist keine Adresse.",
"instanceAddressRequired": "Gib die Adresse einer Instanz ein.",
"instanceContinue": "Weiter",
"instanceUnreachable": "Unter {name} antwortet keine Instanz von {app}.",
"instanceUseBuiltIn": "{name} verwenden",
"instanceOpenFailed": "Die App auf {name} konnte nicht geöffnet werden.",
"instanceDataAndBackupsIn": "Daten und Sicherungen: {place}",
"instanceDataIn": "Daten: {place}",
"instanceBackupsIn": "Sicherungen: {place}",
"instanceNoBackups": "Keine Sicherungen",
"instanceEuropeanUnion": "Europäische Union",
"accountsTitle": "Konten",
"accountAdd": "Konto hinzufügen",
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",
"accountSignedIn": "Angemeldet",
"accountAnonymous": "Anonym",
"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 wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.",
"accountRemoveConfirm": "Entfernen",
"sessionUnreachable": "Der Server war nicht erreichbar.",
"personDataFailed": "Die Daten dieses Geräts konnten nicht geöffnet werden.",
"startSignedOut": "Abgemeldet",
"startOn": "Starte auf {instance}:",
"startWithGitHub": "Mit GitHub anmelden",
"startWithEmail": "Mit E-Mail fortfahren",
"startWithoutAccount": "Ohne Konto starten",
"startAnonymous": "Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.",
"startGitHubFailed": "Anmeldung mit GitHub fehlgeschlagen.",
"startNoGitHubAccount": "Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Starte ohne Konto und füge GitHub dann im Profil hinzu.",
"startOtherInstance": "Andere Instanz verwenden",
"emailSignInTitle": "Mit E-Mail fortfahren",
"emailSignInIntro": "Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.",
"emailSignInAddress": "E-Mail-Adresse",
"emailSignInSend": "Code senden",
"emailSignInCodeSent": "Wir haben einen Code an {address} geschickt. Er gilt 15 Minuten.",
"emailSignInResent": "Ein neuer Code ist unterwegs.",
"emailSignInCode": "Code",
"emailSignInContinue": "Weiter",
"emailSignInResend": "Neuen Code senden",
"emailSignInOtherAddress": "Andere Adresse verwenden",
"emailSignInSwitchTitle": "Zum Konto von {address} wechseln?",
"emailSignInSwitchConfirm": "Wechseln",
"emailSignInSwitchStay": "Anonym bleiben",
"emailSignInTaken": "Mit {address} meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.",
"emailSignInUnavailable": "Diese Instanz bietet keine Anmeldung mit E-Mail an.",
"emailSignInNotAnAddress": "Das ist keine E-Mail-Adresse.",
"emailSignInTooManyCodes": "Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.",
"emailSignInWrongCode": "Dieser Code stimmt nicht.",
"emailSignInTooManyTries": "Zu viele falsche Codes. Fordere einen neuen an.",
"emailSignInCodeExpired": "Dieser Code gilt nicht mehr. Fordere einen neuen an.",
"emailSignInOtherAddressLinked": "Du meldest dich schon mit einer anderen Adresse an.",
"emailSignInMailFailed": "Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.",
"emailSignInFailed": "Die Instanz war nicht erreichbar. Versuche es noch einmal.",
"syncTitle": "Synchronisierung",
"syncNow": "Jetzt synchronisieren",
"syncStateSyncing": "Wird synchronisiert…",
"syncStateSynced": "Alles synchronisiert",
"syncStateFailing": "Synchronisierung schlägt fehl",
"syncStateNotYet": "Noch nicht synchronisiert",
"syncStateMismatch": "Diese App kann nicht mit {server} synchronisieren",
"syncMismatchDetail": "Aktualisiere die App. Ist sie schon aktuell, muss der Server aktualisiert werden. Was du bis dahin tust, bleibt auf diesem Gerät und wird danach synchronisiert.",
"syncLastCompleted": "Zuletzt abgeschlossen um {time}",
"syncLastFailed": "Letzter Versuch fehlgeschlagen um {time}",
"syncNoRunYet": "Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen",
"syncPending": "{count, plural, =1{1 Änderung wartet auf die Synchronisierung} other{{count} Änderungen warten auf die Synchronisierung}}",
"syncTroubleLabel": "Synchronisierung benötigt Aufmerksamkeit",
"syncMismatchBanner": "Diese App kann nicht mehr mit {server} synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.",
"syncMismatchMore": "Mehr"
}

View file

@ -0,0 +1,201 @@
{
"@@locale": "en",
"tryAgain": "Try again",
"instanceWelcome": "Welcome to {app}",
"@instanceWelcome": {
"description": "Screen readers hear it for the wordmark on the first start.",
"placeholders": {
"app": {
"type": "String"
}
}
},
"instanceBuiltInTagline": "Comes with the app",
"instanceAnother": "Another instance",
"instanceAnotherTagline": "Enter its address",
"instanceAddress": "Instance address",
"instanceAddressInvalid": "This isn't an address.",
"instanceAddressRequired": "Enter the address of an instance.",
"instanceContinue": "Continue",
"instanceUnreachable": "No instance of {app} answers at {name}.",
"@instanceUnreachable": {
"placeholders": {
"app": {
"type": "String"
},
"name": {
"type": "String"
}
}
},
"instanceUseBuiltIn": "Use {name}",
"@instanceUseBuiltIn": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"instanceOpenFailed": "Couldn't open the app on {name}.",
"@instanceOpenFailed": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"instanceDataAndBackupsIn": "Data and backups: {place}",
"@instanceDataAndBackupsIn": {
"placeholders": {
"place": {
"type": "String"
}
}
},
"instanceDataIn": "Data: {place}",
"@instanceDataIn": {
"placeholders": {
"place": {
"type": "String"
}
}
},
"instanceBackupsIn": "Backups: {place}",
"@instanceBackupsIn": {
"placeholders": {
"place": {
"type": "String"
}
}
},
"instanceNoBackups": "No backups",
"instanceEuropeanUnion": "European Union",
"accountsTitle": "Accounts",
"accountAdd": "Add account",
"accountAddSubtitle": "Choose the instance for the account to add.",
"accountSignedIn": "Signed in",
"accountAnonymous": "Anonymous",
"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 again to have it. An anonymous account can't be signed into again.",
"@accountRemoveMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"accountRemoveConfirm": "Remove",
"sessionUnreachable": "Couldn't reach the server.",
"personDataFailed": "Couldn't open this device's data.",
"startSignedOut": "Signed out",
"startOn": "Start on {instance}:",
"@startOn": {
"placeholders": {
"instance": {
"type": "String"
}
}
},
"startWithGitHub": "Sign in with GitHub",
"startWithEmail": "Continue with email",
"startWithoutAccount": "Start without an account",
"startAnonymous": "Progress is saved on this device. Sign in to keep it.",
"startGitHubFailed": "Couldn't sign in with GitHub.",
"startNoGitHubAccount": "No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.",
"startOtherInstance": "Use another instance",
"emailSignInTitle": "Continue with email",
"emailSignInIntro": "We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.",
"emailSignInAddress": "Email address",
"emailSignInSend": "Send code",
"emailSignInCodeSent": "We mailed a code to {address}. It's valid for 15 minutes.",
"@emailSignInCodeSent": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInResent": "A new code is on its way.",
"emailSignInCode": "Code",
"emailSignInContinue": "Continue",
"emailSignInResend": "Send a new code",
"emailSignInOtherAddress": "Use another address",
"emailSignInSwitchTitle": "Switch to the account of {address}?",
"@emailSignInSwitchTitle": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInSwitchConfirm": "Switch",
"emailSignInSwitchStay": "Stay anonymous",
"emailSignInTaken": "{address} already signs in to another account. To switch to it, sign out first.",
"@emailSignInTaken": {
"placeholders": {
"address": {
"type": "String"
}
}
},
"emailSignInUnavailable": "This instance doesn't offer signing in with email.",
"emailSignInNotAnAddress": "This isn't an email address.",
"emailSignInTooManyCodes": "Too many codes in the last hour. Try again later.",
"emailSignInWrongCode": "That code isn't right.",
"emailSignInTooManyTries": "Too many wrong codes. Ask for a new one.",
"emailSignInCodeExpired": "This code is no longer valid. Ask for a new one.",
"emailSignInOtherAddressLinked": "You already sign in with another address.",
"emailSignInMailFailed": "The instance couldn't send the mail. Try again later.",
"emailSignInFailed": "Couldn't reach the instance. Try again.",
"syncTitle": "Sync",
"syncNow": "Sync now",
"syncStateSyncing": "Syncing…",
"syncStateSynced": "Everything synced",
"syncStateFailing": "Sync is failing",
"syncStateNotYet": "Not synced yet",
"syncStateMismatch": "This app can't sync with {server}",
"@syncStateMismatch": {
"placeholders": {
"server": {
"type": "String"
}
}
},
"syncMismatchDetail": "Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.",
"syncLastCompleted": "Last completed at {time}",
"@syncLastCompleted": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"syncLastFailed": "Last attempt failed at {time}",
"@syncLastFailed": {
"placeholders": {
"time": {
"type": "String"
}
}
},
"syncNoRunYet": "No sync has completed since the app started",
"syncPending": "{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}",
"@syncPending": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"syncTroubleLabel": "Sync needs attention",
"syncMismatchBanner": "This app can't sync with {server} any more. Update it to keep syncing.",
"@syncMismatchBanner": {
"placeholders": {
"server": {
"type": "String"
}
}
},
"syncMismatchMore": "More"
}

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_GB"
}

View file

@ -0,0 +1,3 @@
{
"@@locale": "en_US"
}

View file

@ -0,0 +1,605 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'halkyon_app_localizations_de.dart';
import 'halkyon_app_localizations_en.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of HalkyonAppLocalizations
/// returned by `HalkyonAppLocalizations.of(context)`.
///
/// Applications need to include `HalkyonAppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/halkyon_app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: HalkyonAppLocalizations.localizationsDelegates,
/// supportedLocales: HalkyonAppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the HalkyonAppLocalizations.supportedLocales
/// property.
abstract class HalkyonAppLocalizations {
HalkyonAppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static HalkyonAppLocalizations of(BuildContext context) {
return Localizations.of<HalkyonAppLocalizations>(
context,
HalkyonAppLocalizations,
)!;
}
static const LocalizationsDelegate<HalkyonAppLocalizations> delegate =
_HalkyonAppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('de'),
Locale('en'),
Locale('en', 'GB'),
Locale('en', 'US'),
];
/// No description provided for @tryAgain.
///
/// In en, this message translates to:
/// **'Try again'**
String get tryAgain;
/// Screen readers hear it for the wordmark on the first start.
///
/// In en, this message translates to:
/// **'Welcome to {app}'**
String instanceWelcome(String app);
/// No description provided for @instanceBuiltInTagline.
///
/// In en, this message translates to:
/// **'Comes with the app'**
String get instanceBuiltInTagline;
/// No description provided for @instanceAnother.
///
/// In en, this message translates to:
/// **'Another instance'**
String get instanceAnother;
/// No description provided for @instanceAnotherTagline.
///
/// In en, this message translates to:
/// **'Enter its address'**
String get instanceAnotherTagline;
/// No description provided for @instanceAddress.
///
/// In en, this message translates to:
/// **'Instance address'**
String get instanceAddress;
/// No description provided for @instanceAddressInvalid.
///
/// In en, this message translates to:
/// **'This isn\'t an address.'**
String get instanceAddressInvalid;
/// No description provided for @instanceAddressRequired.
///
/// In en, this message translates to:
/// **'Enter the address of an instance.'**
String get instanceAddressRequired;
/// No description provided for @instanceContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get instanceContinue;
/// No description provided for @instanceUnreachable.
///
/// In en, this message translates to:
/// **'No instance of {app} answers at {name}.'**
String instanceUnreachable(String app, String name);
/// No description provided for @instanceUseBuiltIn.
///
/// In en, this message translates to:
/// **'Use {name}'**
String instanceUseBuiltIn(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 @instanceDataAndBackupsIn.
///
/// In en, this message translates to:
/// **'Data and backups: {place}'**
String instanceDataAndBackupsIn(String place);
/// No description provided for @instanceDataIn.
///
/// In en, this message translates to:
/// **'Data: {place}'**
String instanceDataIn(String place);
/// No description provided for @instanceBackupsIn.
///
/// In en, this message translates to:
/// **'Backups: {place}'**
String instanceBackupsIn(String place);
/// No description provided for @instanceNoBackups.
///
/// In en, this message translates to:
/// **'No backups'**
String get instanceNoBackups;
/// No description provided for @instanceEuropeanUnion.
///
/// In en, this message translates to:
/// **'European Union'**
String get instanceEuropeanUnion;
/// 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 @accountSignedIn.
///
/// In en, this message translates to:
/// **'Signed in'**
String get accountSignedIn;
/// No description provided for @accountAnonymous.
///
/// In en, this message translates to:
/// **'Anonymous'**
String get accountAnonymous;
/// 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 again to have it. 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 @sessionUnreachable.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the server.'**
String get sessionUnreachable;
/// No description provided for @personDataFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t open this device\'s data.'**
String get personDataFailed;
/// No description provided for @startSignedOut.
///
/// In en, this message translates to:
/// **'Signed out'**
String get startSignedOut;
/// No description provided for @startOn.
///
/// In en, this message translates to:
/// **'Start on {instance}:'**
String startOn(String instance);
/// No description provided for @startWithGitHub.
///
/// In en, this message translates to:
/// **'Sign in with GitHub'**
String get startWithGitHub;
/// No description provided for @startWithEmail.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get startWithEmail;
/// No description provided for @startWithoutAccount.
///
/// In en, this message translates to:
/// **'Start without an account'**
String get startWithoutAccount;
/// No description provided for @startAnonymous.
///
/// In en, this message translates to:
/// **'Progress is saved on this device. Sign in to keep it.'**
String get startAnonymous;
/// No description provided for @startGitHubFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t sign in with GitHub.'**
String get startGitHubFailed;
/// No description provided for @startNoGitHubAccount.
///
/// In en, this message translates to:
/// **'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.'**
String get startNoGitHubAccount;
/// No description provided for @startOtherInstance.
///
/// In en, this message translates to:
/// **'Use another instance'**
String get startOtherInstance;
/// No description provided for @emailSignInTitle.
///
/// In en, this message translates to:
/// **'Continue with email'**
String get emailSignInTitle;
/// No description provided for @emailSignInIntro.
///
/// In en, this message translates to:
/// **'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.'**
String get emailSignInIntro;
/// No description provided for @emailSignInAddress.
///
/// In en, this message translates to:
/// **'Email address'**
String get emailSignInAddress;
/// No description provided for @emailSignInSend.
///
/// In en, this message translates to:
/// **'Send code'**
String get emailSignInSend;
/// No description provided for @emailSignInCodeSent.
///
/// In en, this message translates to:
/// **'We mailed a code to {address}. It\'s valid for 15 minutes.'**
String emailSignInCodeSent(String address);
/// No description provided for @emailSignInResent.
///
/// In en, this message translates to:
/// **'A new code is on its way.'**
String get emailSignInResent;
/// No description provided for @emailSignInCode.
///
/// In en, this message translates to:
/// **'Code'**
String get emailSignInCode;
/// No description provided for @emailSignInContinue.
///
/// In en, this message translates to:
/// **'Continue'**
String get emailSignInContinue;
/// No description provided for @emailSignInResend.
///
/// In en, this message translates to:
/// **'Send a new code'**
String get emailSignInResend;
/// No description provided for @emailSignInOtherAddress.
///
/// In en, this message translates to:
/// **'Use another address'**
String get emailSignInOtherAddress;
/// No description provided for @emailSignInSwitchTitle.
///
/// In en, this message translates to:
/// **'Switch to the account of {address}?'**
String emailSignInSwitchTitle(String address);
/// No description provided for @emailSignInSwitchConfirm.
///
/// In en, this message translates to:
/// **'Switch'**
String get emailSignInSwitchConfirm;
/// No description provided for @emailSignInSwitchStay.
///
/// In en, this message translates to:
/// **'Stay anonymous'**
String get emailSignInSwitchStay;
/// No description provided for @emailSignInTaken.
///
/// In en, this message translates to:
/// **'{address} already signs in to another account. To switch to it, sign out first.'**
String emailSignInTaken(String address);
/// No description provided for @emailSignInUnavailable.
///
/// In en, this message translates to:
/// **'This instance doesn\'t offer signing in with email.'**
String get emailSignInUnavailable;
/// No description provided for @emailSignInNotAnAddress.
///
/// In en, this message translates to:
/// **'This isn\'t an email address.'**
String get emailSignInNotAnAddress;
/// No description provided for @emailSignInTooManyCodes.
///
/// In en, this message translates to:
/// **'Too many codes in the last hour. Try again later.'**
String get emailSignInTooManyCodes;
/// No description provided for @emailSignInWrongCode.
///
/// In en, this message translates to:
/// **'That code isn\'t right.'**
String get emailSignInWrongCode;
/// No description provided for @emailSignInTooManyTries.
///
/// In en, this message translates to:
/// **'Too many wrong codes. Ask for a new one.'**
String get emailSignInTooManyTries;
/// No description provided for @emailSignInCodeExpired.
///
/// In en, this message translates to:
/// **'This code is no longer valid. Ask for a new one.'**
String get emailSignInCodeExpired;
/// No description provided for @emailSignInOtherAddressLinked.
///
/// In en, this message translates to:
/// **'You already sign in with another address.'**
String get emailSignInOtherAddressLinked;
/// No description provided for @emailSignInMailFailed.
///
/// In en, this message translates to:
/// **'The instance couldn\'t send the mail. Try again later.'**
String get emailSignInMailFailed;
/// No description provided for @emailSignInFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach the instance. Try again.'**
String get emailSignInFailed;
/// No description provided for @syncTitle.
///
/// In en, this message translates to:
/// **'Sync'**
String get syncTitle;
/// No description provided for @syncNow.
///
/// In en, this message translates to:
/// **'Sync now'**
String get syncNow;
/// No description provided for @syncStateSyncing.
///
/// In en, this message translates to:
/// **'Syncing…'**
String get syncStateSyncing;
/// No description provided for @syncStateSynced.
///
/// In en, this message translates to:
/// **'Everything synced'**
String get syncStateSynced;
/// No description provided for @syncStateFailing.
///
/// In en, this message translates to:
/// **'Sync is failing'**
String get syncStateFailing;
/// No description provided for @syncStateNotYet.
///
/// In en, this message translates to:
/// **'Not synced yet'**
String get syncStateNotYet;
/// No description provided for @syncStateMismatch.
///
/// In en, this message translates to:
/// **'This app can\'t sync with {server}'**
String syncStateMismatch(String server);
/// No description provided for @syncMismatchDetail.
///
/// In en, this message translates to:
/// **'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.'**
String get syncMismatchDetail;
/// No description provided for @syncLastCompleted.
///
/// In en, this message translates to:
/// **'Last completed at {time}'**
String syncLastCompleted(String time);
/// No description provided for @syncLastFailed.
///
/// In en, this message translates to:
/// **'Last attempt failed at {time}'**
String syncLastFailed(String time);
/// No description provided for @syncNoRunYet.
///
/// In en, this message translates to:
/// **'No sync has completed since the app started'**
String get syncNoRunYet;
/// No description provided for @syncPending.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 change waits to sync} other{{count} changes wait to sync}}'**
String syncPending(int count);
/// No description provided for @syncTroubleLabel.
///
/// In en, this message translates to:
/// **'Sync needs attention'**
String get syncTroubleLabel;
/// No description provided for @syncMismatchBanner.
///
/// In en, this message translates to:
/// **'This app can\'t sync with {server} any more. Update it to keep syncing.'**
String syncMismatchBanner(String server);
/// No description provided for @syncMismatchMore.
///
/// In en, this message translates to:
/// **'More'**
String get syncMismatchMore;
}
class _HalkyonAppLocalizationsDelegate
extends LocalizationsDelegate<HalkyonAppLocalizations> {
const _HalkyonAppLocalizationsDelegate();
@override
Future<HalkyonAppLocalizations> load(Locale locale) {
return SynchronousFuture<HalkyonAppLocalizations>(
lookupHalkyonAppLocalizations(locale),
);
}
@override
bool isSupported(Locale locale) =>
<String>['de', 'en'].contains(locale.languageCode);
@override
bool shouldReload(_HalkyonAppLocalizationsDelegate old) => false;
}
HalkyonAppLocalizations lookupHalkyonAppLocalizations(Locale locale) {
// Lookup logic when language+country codes are specified.
switch (locale.languageCode) {
case 'en':
{
switch (locale.countryCode) {
case 'GB':
return HalkyonAppLocalizationsEnGb();
case 'US':
return HalkyonAppLocalizationsEnUs();
}
break;
}
}
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'de':
return HalkyonAppLocalizationsDe();
case 'en':
return HalkyonAppLocalizationsEn();
}
throw FlutterError(
'HalkyonAppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}

View file

@ -0,0 +1,290 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'halkyon_app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for German (`de`).
class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
HalkyonAppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get tryAgain => 'Erneut versuchen';
@override
String instanceWelcome(String app) {
return 'Willkommen bei $app';
}
@override
String get instanceBuiltInTagline => 'In der App voreingestellt';
@override
String get instanceAnother => 'Andere Instanz';
@override
String get instanceAnotherTagline => 'Gib ihre Adresse ein';
@override
String get instanceAddress => 'Adresse der Instanz';
@override
String get instanceAddressInvalid => 'Das ist keine Adresse.';
@override
String get instanceAddressRequired => 'Gib die Adresse einer Instanz ein.';
@override
String get instanceContinue => 'Weiter';
@override
String instanceUnreachable(String app, String name) {
return 'Unter $name antwortet keine Instanz von $app.';
}
@override
String instanceUseBuiltIn(String name) {
return '$name verwenden';
}
@override
String instanceOpenFailed(String name) {
return 'Die App auf $name konnte nicht geöffnet werden.';
}
@override
String instanceDataAndBackupsIn(String place) {
return 'Daten und Sicherungen: $place';
}
@override
String instanceDataIn(String place) {
return 'Daten: $place';
}
@override
String instanceBackupsIn(String place) {
return 'Sicherungen: $place';
}
@override
String get instanceNoBackups => 'Keine Sicherungen';
@override
String get instanceEuropeanUnion => 'Europäische Union';
@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 accountSignedIn => 'Angemeldet';
@override
String get accountAnonymous => 'Anonym';
@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 wieder an, um es wiederzuhaben. In ein anonymes Konto kann man sich nicht wieder anmelden.';
}
@override
String get accountRemoveConfirm => 'Entfernen';
@override
String get sessionUnreachable => 'Der Server war nicht erreichbar.';
@override
String get personDataFailed =>
'Die Daten dieses Geräts konnten nicht geöffnet werden.';
@override
String get startSignedOut => 'Abgemeldet';
@override
String startOn(String instance) {
return 'Starte auf $instance:';
}
@override
String get startWithGitHub => 'Mit GitHub anmelden';
@override
String get startWithEmail => 'Mit E-Mail fortfahren';
@override
String get startWithoutAccount => 'Ohne Konto starten';
@override
String get startAnonymous =>
'Dein Fortschritt wird auf diesem Gerät gespeichert. Melde dich an, um ihn zu behalten.';
@override
String get startGitHubFailed => 'Anmeldung mit GitHub fehlgeschlagen.';
@override
String get startNoGitHubAccount =>
'Noch kein Konto hier meldet sich mit diesem GitHub-Konto an. Starte ohne Konto und füge GitHub dann im Profil hinzu.';
@override
String get startOtherInstance => 'Andere Instanz verwenden';
@override
String get emailSignInTitle => 'Mit E-Mail fortfahren';
@override
String get emailSignInIntro =>
'Wir schicken dir einen Code zum Anmelden. Diese Instanz behält nur einen Fingerabdruck deiner Adresse, nie die Adresse selbst, und schreibt ihr nur, wenn du einen Code anforderst.';
@override
String get emailSignInAddress => 'E-Mail-Adresse';
@override
String get emailSignInSend => 'Code senden';
@override
String emailSignInCodeSent(String address) {
return 'Wir haben einen Code an $address geschickt. Er gilt 15 Minuten.';
}
@override
String get emailSignInResent => 'Ein neuer Code ist unterwegs.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Weiter';
@override
String get emailSignInResend => 'Neuen Code senden';
@override
String get emailSignInOtherAddress => 'Andere Adresse verwenden';
@override
String emailSignInSwitchTitle(String address) {
return 'Zum Konto von $address wechseln?';
}
@override
String get emailSignInSwitchConfirm => 'Wechseln';
@override
String get emailSignInSwitchStay => 'Anonym bleiben';
@override
String emailSignInTaken(String address) {
return 'Mit $address meldet sich schon ein anderes Konto an. Um zu ihm zu wechseln, melde dich zuerst ab.';
}
@override
String get emailSignInUnavailable =>
'Diese Instanz bietet keine Anmeldung mit E-Mail an.';
@override
String get emailSignInNotAnAddress => 'Das ist keine E-Mail-Adresse.';
@override
String get emailSignInTooManyCodes =>
'Zu viele Codes in der letzten Stunde. Versuche es später noch einmal.';
@override
String get emailSignInWrongCode => 'Dieser Code stimmt nicht.';
@override
String get emailSignInTooManyTries =>
'Zu viele falsche Codes. Fordere einen neuen an.';
@override
String get emailSignInCodeExpired =>
'Dieser Code gilt nicht mehr. Fordere einen neuen an.';
@override
String get emailSignInOtherAddressLinked =>
'Du meldest dich schon mit einer anderen Adresse an.';
@override
String get emailSignInMailFailed =>
'Die Instanz konnte die Mail nicht senden. Versuche es später noch einmal.';
@override
String get emailSignInFailed =>
'Die Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get syncTitle => 'Synchronisierung';
@override
String get syncNow => 'Jetzt synchronisieren';
@override
String get syncStateSyncing => 'Wird synchronisiert…';
@override
String get syncStateSynced => 'Alles synchronisiert';
@override
String get syncStateFailing => 'Synchronisierung schlägt fehl';
@override
String get syncStateNotYet => 'Noch nicht synchronisiert';
@override
String syncStateMismatch(String server) {
return 'Diese App kann nicht mit $server synchronisieren';
}
@override
String get syncMismatchDetail =>
'Aktualisiere die App. Ist sie schon aktuell, muss der Server aktualisiert werden. Was du bis dahin tust, bleibt auf diesem Gerät und wird danach synchronisiert.';
@override
String syncLastCompleted(String time) {
return 'Zuletzt abgeschlossen um $time';
}
@override
String syncLastFailed(String time) {
return 'Letzter Versuch fehlgeschlagen um $time';
}
@override
String get syncNoRunYet =>
'Seit dem Start der App wurde noch keine Synchronisierung abgeschlossen';
@override
String syncPending(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Änderungen warten auf die Synchronisierung',
one: '1 Änderung wartet auf die Synchronisierung',
);
return '$_temp0';
}
@override
String get syncTroubleLabel => 'Synchronisierung benötigt Aufmerksamkeit';
@override
String syncMismatchBanner(String server) {
return 'Diese App kann nicht mehr mit $server synchronisieren. Aktualisiere sie, um weiter zu synchronisieren.';
}
@override
String get syncMismatchMore => 'Mehr';
}

View file

@ -0,0 +1,297 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'halkyon_app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
HalkyonAppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get tryAgain => 'Try again';
@override
String instanceWelcome(String app) {
return 'Welcome to $app';
}
@override
String get instanceBuiltInTagline => 'Comes with the app';
@override
String get instanceAnother => 'Another instance';
@override
String get instanceAnotherTagline => 'Enter its address';
@override
String get instanceAddress => 'Instance address';
@override
String get instanceAddressInvalid => 'This isn\'t an address.';
@override
String get instanceAddressRequired => 'Enter the address of an instance.';
@override
String get instanceContinue => 'Continue';
@override
String instanceUnreachable(String app, String name) {
return 'No instance of $app answers at $name.';
}
@override
String instanceUseBuiltIn(String name) {
return 'Use $name';
}
@override
String instanceOpenFailed(String name) {
return 'Couldn\'t open the app on $name.';
}
@override
String instanceDataAndBackupsIn(String place) {
return 'Data and backups: $place';
}
@override
String instanceDataIn(String place) {
return 'Data: $place';
}
@override
String instanceBackupsIn(String place) {
return 'Backups: $place';
}
@override
String get instanceNoBackups => 'No backups';
@override
String get instanceEuropeanUnion => 'European Union';
@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 accountSignedIn => 'Signed in';
@override
String get accountAnonymous => 'Anonymous';
@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 again to have it. An anonymous account can\'t be signed into again.';
}
@override
String get accountRemoveConfirm => 'Remove';
@override
String get sessionUnreachable => 'Couldn\'t reach the server.';
@override
String get personDataFailed => 'Couldn\'t open this device\'s data.';
@override
String get startSignedOut => 'Signed out';
@override
String startOn(String instance) {
return 'Start on $instance:';
}
@override
String get startWithGitHub => 'Sign in with GitHub';
@override
String get startWithEmail => 'Continue with email';
@override
String get startWithoutAccount => 'Start without an account';
@override
String get startAnonymous =>
'Progress is saved on this device. Sign in to keep it.';
@override
String get startGitHubFailed => 'Couldn\'t sign in with GitHub.';
@override
String get startNoGitHubAccount =>
'No account here signs in with this GitHub account yet. Start without an account, then add GitHub in Profile.';
@override
String get startOtherInstance => 'Use another instance';
@override
String get emailSignInTitle => 'Continue with email';
@override
String get emailSignInIntro =>
'We mail you a code to sign in. This instance keeps only a fingerprint of your address, never the address itself, and writes to it only when you ask for a code.';
@override
String get emailSignInAddress => 'Email address';
@override
String get emailSignInSend => 'Send code';
@override
String emailSignInCodeSent(String address) {
return 'We mailed a code to $address. It\'s valid for 15 minutes.';
}
@override
String get emailSignInResent => 'A new code is on its way.';
@override
String get emailSignInCode => 'Code';
@override
String get emailSignInContinue => 'Continue';
@override
String get emailSignInResend => 'Send a new code';
@override
String get emailSignInOtherAddress => 'Use another address';
@override
String emailSignInSwitchTitle(String address) {
return 'Switch to the account of $address?';
}
@override
String get emailSignInSwitchConfirm => 'Switch';
@override
String get emailSignInSwitchStay => 'Stay anonymous';
@override
String emailSignInTaken(String address) {
return '$address already signs in to another account. To switch to it, sign out first.';
}
@override
String get emailSignInUnavailable =>
'This instance doesn\'t offer signing in with email.';
@override
String get emailSignInNotAnAddress => 'This isn\'t an email address.';
@override
String get emailSignInTooManyCodes =>
'Too many codes in the last hour. Try again later.';
@override
String get emailSignInWrongCode => 'That code isn\'t right.';
@override
String get emailSignInTooManyTries =>
'Too many wrong codes. Ask for a new one.';
@override
String get emailSignInCodeExpired =>
'This code is no longer valid. Ask for a new one.';
@override
String get emailSignInOtherAddressLinked =>
'You already sign in with another address.';
@override
String get emailSignInMailFailed =>
'The instance couldn\'t send the mail. Try again later.';
@override
String get emailSignInFailed => 'Couldn\'t reach the instance. Try again.';
@override
String get syncTitle => 'Sync';
@override
String get syncNow => 'Sync now';
@override
String get syncStateSyncing => 'Syncing…';
@override
String get syncStateSynced => 'Everything synced';
@override
String get syncStateFailing => 'Sync is failing';
@override
String get syncStateNotYet => 'Not synced yet';
@override
String syncStateMismatch(String server) {
return 'This app can\'t sync with $server';
}
@override
String get syncMismatchDetail =>
'Update the app. If it is up to date, the server needs an update. What you do meanwhile stays on this device and syncs afterwards.';
@override
String syncLastCompleted(String time) {
return 'Last completed at $time';
}
@override
String syncLastFailed(String time) {
return 'Last attempt failed at $time';
}
@override
String get syncNoRunYet => 'No sync has completed since the app started';
@override
String syncPending(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count changes wait to sync',
one: '1 change waits to sync',
);
return '$_temp0';
}
@override
String get syncTroubleLabel => 'Sync needs attention';
@override
String syncMismatchBanner(String server) {
return 'This app can\'t sync with $server any more. Update it to keep syncing.';
}
@override
String get syncMismatchMore => 'More';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).
class HalkyonAppLocalizationsEnGb extends HalkyonAppLocalizationsEn {
HalkyonAppLocalizationsEnGb() : super('en_GB');
}
/// The translations for English, as used in the United States (`en_US`).
class HalkyonAppLocalizationsEnUs extends HalkyonAppLocalizationsEn {
HalkyonAppLocalizationsEnUs() : super('en_US');
}

View file

@ -0,0 +1,96 @@
import 'package:material_ui/material_ui.dart';
/// The device's choices this package's own screens follow while no person's
/// app shows: the theme mode, and the language (null: the system's).
typedef LookSettings = ({ThemeMode themeMode, Locale? locale});
/// What this package's own screens need to know of the app they are part
/// of: its name and look, and the few words only it can say. `InstanceGate`
/// hands it down ([AppFrameScope]) to every screen beneath, the person's
/// app included.
final class const AppFrame({
/// The app's name in the language of the context it is asked in ("Halkyon
/// Learn"): the window's title, and where this package's texts name the
/// app.
required final String Function(BuildContext context) name,
/// The app's word in its wordmark, under "Halkyon" ("Learn").
required final String Function(BuildContext context) product,
/// The app's icon, as the first start shows it on the tile of the
/// instance the build names.
required final ImageProvider icon,
/// An instance's address as the address field's hint shows it
/// (`learn.example.org`).
required final String exampleAddress,
/// What the first start says under the wordmark: what the instance chosen
/// keeps of the person's ("Choose the instance to keep your studysets
/// on.").
required final String Function(BuildContext context) firstStartSubtitle,
/// What switching this device to another account leaves behind, as the
/// email sign-in says before it switches ("The studysets on this device
/// can't be combined with it yet: if you switch, they are deleted.").
required final String Function(BuildContext context) switchLeavesBehind,
/// The app's theme in a [Brightness].
required final ThemeData Function(Brightness brightness) theme,
/// The app's localizations, this package's
/// (`HalkyonAppLocalizations.delegate`) and halkyon_ui's among them.
required final List<LocalizationsDelegate<Object?>> localizationsDelegates,
/// The languages the app speaks.
required final List<Locale> supportedLocales,
/// Picks the app's language from the device's, as its own `MaterialApp`
/// does.
final LocaleListResolutionCallback? localeListResolutionCallback,
/// What shows while the app opens.
required final Widget launchScreen,
});
/// Hands the [AppFrame] down to every screen beneath it.
class const AppFrameScope({
/// The app's frame.
required final AppFrame frame,
required super.child,
super.key,
}) extends InheritedWidget {
/// The frame above [context].
static AppFrame of(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<AppFrameScope>();
assert(scope != null, 'No AppFrameScope above this widget.');
return scope!.frame;
}
@override
bool updateShouldNotify(AppFrameScope oldWidget) => frame != oldWidget.frame;
}
/// The app's look around a single screen of this package's, while no
/// person's app shows, as [look] says.
class const PlainApp({
required final LookSettings look,
required final Widget home,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final frame = AppFrameScope.of(context);
return MaterialApp(
onGenerateTitle: frame.name,
theme: frame.theme(Brightness.light),
darkTheme: frame.theme(Brightness.dark),
themeMode: look.themeMode,
locale: look.locale,
localeListResolutionCallback: frame.localeListResolutionCallback,
localizationsDelegates: frame.localizationsDelegates,
supportedLocales: frame.supportedLocales,
home: home,
);
}
}

View file

@ -0,0 +1,57 @@
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/data/signed_out_mark.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
/// What can be closed once it is no longer needed: an app's dependencies on
/// an instance, or a person's.
abstract interface class Disposable() {
/// Closes it; nothing of it is used afterwards.
Future<void> dispose();
}
/// What this package needs of an app's dependencies on one instance (its
/// composition root), which `InstanceGate` opens for the instance in use and
/// [AppScope] hands down. An app's own class implements it beside its
/// features' repositories.
abstract interface class AppDependencies<P extends Disposable>()
implements Disposable {
/// The instance's name as people know it (its host), for messages about
/// it.
String get serverName;
/// Who is signed in, and signing in and out.
AuthRepository get auth;
/// Whether the person here signed out, so the app asks how to start.
SignedOutMark get signedOut;
/// Opens the dependencies of [person], once the previous person's have
/// closed; `PersonScope` disposes them when the person goes.
Future<P> openPerson(SignedIn person);
}
/// Hands an app's [dependencies] on the instance in use down the widget
/// tree; `InstanceGate` puts it above the app.
class const AppScope({
/// The app's dependencies.
required final AppDependencies<Disposable> dependencies,
required super.child,
super.key,
}) extends InheritedWidget {
/// The dependencies above [context], as the app's own type [D]
/// (`AppScope.of<Dependencies>(context)`), or as [AppDependencies] where
/// that is all that is needed.
///
/// Doesn't register [context] for rebuilds: the dependencies of one app run
/// never change.
static D of<D extends AppDependencies<Disposable>>(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'No AppScope above this widget.');
return scope!.dependencies as D;
}
@override
bool updateShouldNotify(AppScope oldWidget) =>
dependencies != oldWidget.dependencies;
}

View file

@ -0,0 +1,184 @@
import 'dart:async';
import 'dart:developer';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_frame.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/instance/country_names.dart';
import 'package:halkyon_app/src/instance/instance.dart';
import 'package:halkyon_app/src/instance/instance_checker.dart';
import 'package:halkyon_app/src/instance/instance_choice.dart';
import 'package:halkyon_app/src/instance/instance_scope.dart';
import 'package:halkyon_app/src/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 ([FirstStartScreen]), then [app] on its
/// dependencies ([AppScope]), opened by [open] anew whenever the person
/// switches, the previous ones closed first. Above it all, [frame]
/// ([AppFrameScope]).
///
/// [app] is built anew for every instance (keyed by it), so nothing of one
/// instance's app reaches another's.
class const InstanceGate({
/// The app's name, look and own words, for this package's screens.
required final AppFrame frame,
required final InstanceChoice choice,
/// Checks an address before the app moves there.
required final InstanceChecker checker,
/// Names the countries instances keep their data in.
required final CountryNames countryNames,
/// The dependencies of the app on an instance.
required final Future<AppDependencies<Disposable>> Function(
Instance instance,
)
open,
/// Reads the device's theme mode and language, for the look while no
/// instance's app shows; read anew as each instance opens, as they may
/// have changed on another.
required final Future<LookSettings> Function() readLook,
/// 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;
AppDependencies<Disposable>? _dependencies;
var _failed = false;
LookSettings _look = (themeMode: ThemeMode.system, locale: null);
/// 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.readLook();
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 AppFrameScope(
frame: widget.frame,
child: InstanceScope(
notifier: widget.choice,
checker: widget.checker,
countryNames: widget.countryNames,
child: switch (current) {
null => PlainApp(look: _look, home: const FirstStartScreen()),
final instance when dependencies != null && _opened == instance =>
AppScope(
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: widget.frame.launchScreen),
},
),
);
}
}
class const _OpenFailed({
required final Instance instance,
required final VoidCallback onRetry,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = HalkyonAppLocalizations.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.tryAgain)),
],
),
),
);
}
}

View file

@ -0,0 +1,91 @@
import 'dart:async';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/app/app_frame.dart';
import 'package:halkyon_app/src/app/app_scope.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:material_ui/material_ui.dart';
/// Opens [person]'s dependencies (`AppDependencies.openPerson`), shows
/// [child] with them, and disposes them when it goes away. `SessionGate`
/// gives every person a new `PersonScope`.
class const PersonScope({
/// Whose dependencies these are.
required final SignedIn person,
/// What the person sees.
required final Widget child,
super.key,
}) extends StatefulWidget {
/// The dependencies of the person signed in above [context], as the app's
/// own type [P] (`PersonScope.of<PersonDependencies>(context)`).
static P of<P extends Disposable>(BuildContext context) {
final scope = context
.getInheritedWidgetOfExactType<_PersonDependenciesScope>();
assert(scope != null, 'No PersonScope above this widget.');
return scope!.dependencies as P;
}
@override
State<PersonScope> createState() => _PersonScopeState();
}
class _PersonScopeState() extends State<PersonScope> {
Future<Disposable>? _opening;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_opening ??= AppScope.of(context).openPerson(widget.person);
}
@override
void dispose() {
unawaited(
_opening?.then(
(dependencies) => dependencies.dispose(),
// Opening failed and said so on screen; there is nothing to close.
onError: (Object _) {},
),
);
super.dispose();
}
@override
Widget build(BuildContext context) => FutureBuilder(
future: _opening,
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(:final data?) => _PersonDependenciesScope(
dependencies: data,
child: widget.child,
),
AsyncSnapshot(hasError: true) => Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(HalkyonAppLocalizations.of(context).personDataFailed),
const SizedBox(height: 16),
FilledButton(
onPressed: () => setState(() {
_opening = AppScope.of(context).openPerson(widget.person);
}),
child: Text(HalkyonAppLocalizations.of(context).tryAgain),
),
],
),
),
),
_ => AppFrameScope.of(context).launchScreen,
},
);
}
class const _PersonDependenciesScope({
required final Disposable dependencies,
required super.child,
}) extends InheritedWidget {
@override
bool updateShouldNotify(_PersonDependenciesScope oldWidget) =>
dependencies != oldWidget.dependencies;
}

View file

@ -0,0 +1,258 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
/// Signs in with an email address, by a code mailed to it, as signing in
/// with GitHub does: a new address becomes the person's, who
/// keeps everything; an address that is another person's switches this
/// device to them, right away when it holds nothing
/// (`deviceHoldsContent`), otherwise once the person confirms that this
/// device's content stays behind. Someone identified is never switched:
/// they are told the address is another account's, as their own account
/// would otherwise be left.
class EmailSignInBloc(
final AuthRepository _auth, {
/// Whether this device holds content of the person signed in that a
/// switch would leave behind, as the app counts it (Halkyon Learn: its
/// studysets, those waiting in Recently deleted too).
required final Future<bool> Function() _deviceHoldsContent,
/// The language the mail is worded in (`en`, `de`).
required final String language,
}) extends Bloc<EmailSignInEvent, EmailSignInState> {
this : super(const EmailSignInStateAddress()) {
on<EmailSignInEventCodeRequested>(_onCodeRequested);
on<EmailSignInEventCodeEntered>(_onCodeEntered);
on<EmailSignInEventCodeResent>(_onCodeResent);
on<EmailSignInEventAddressReopened>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
on<EmailSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<EmailSignInEventSwitchDeclined>(
(event, emit) => emit(const EmailSignInStateAddress()),
);
}
Future<void> _onCodeRequested(
EmailSignInEventCodeRequested event,
Emitter<EmailSignInState> emit,
) async {
// As the instance takes it, and as the person reads it back.
final address = event.address.trim().toLowerCase();
emit(const EmailSignInStateAddress(sending: true));
try {
emit(
EmailSignInStateCode(
address: address,
request: await _auth.sendEmailCode(address, language: language),
),
);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
Future<void> _onCodeResent(
EmailSignInEventCodeResent event,
Emitter<EmailSignInState> emit,
) async {
if (state case final EmailSignInStateCode code) {
emit(code.checked());
try {
emit(
EmailSignInStateCode(
address: code.address,
request: await _auth.sendEmailCode(
code.address,
language: language,
),
resent: true,
),
);
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
}
Future<void> _onCodeEntered(
EmailSignInEventCodeEntered event,
Emitter<EmailSignInState> emit,
) async {
final code = state;
if (code is! EmailSignInStateCode) {
return;
}
emit(code.checked());
try {
switch (await _auth.verifyEmailCode(
code.request,
event.code,
address: code.address,
)) {
case EmailLinked():
emit(EmailSignInStateLinked(address: code.address));
case EmailTakenByAnotherPerson() when _auth.isIdentified:
emit(EmailSignInStateTaken(address: code.address));
case EmailTakenByAnotherPerson(:final switchToken):
if (await _deviceHoldsContent()) {
emit(
EmailSignInStateConfirmSwitch(
address: code.address,
switchToken: switchToken,
),
);
} else {
await _switch(code.address, switchToken, emit);
}
}
} on Exception catch (error, st) {
emit(code.failed(_problemOf(error, st)));
}
}
Future<void> _onSwitchConfirmed(
EmailSignInEventSwitchConfirmed event,
Emitter<EmailSignInState> emit,
) async {
if (state case EmailSignInStateConfirmSwitch(
:final address,
:final switchToken,
)) {
try {
await _switch(address, switchToken, emit);
} on Exception catch (error, st) {
emit(EmailSignInStateAddress(problem: _problemOf(error, st)));
}
}
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(
String address,
String switchToken,
Emitter<EmailSignInState> emit,
) async {
emit(const EmailSignInStateSwitching());
await _auth.switchToEmail(switchToken, address: address);
}
EmailSignInProblem _problemOf(Exception error, StackTrace st) {
if (error is EmailSignInFailed) {
return error.problem;
}
addError(error, st);
return EmailSignInProblem.failed;
}
}
/// What happens to signing in with an email address.
sealed class const EmailSignInEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// Mail a code to [address].
final class const EmailSignInEventCodeRequested(final String address)
extends EmailSignInEvent {
@override
List<Object?> get props => [address];
}
/// The person typed [code], from the mail.
final class const EmailSignInEventCodeEntered(final String code)
extends EmailSignInEvent {
@override
List<Object?> get props => [code];
}
/// Mail a new code to the same address.
final class const EmailSignInEventCodeResent() extends EmailSignInEvent;
/// Type another address.
final class const EmailSignInEventAddressReopened() extends EmailSignInEvent;
/// Switch to the address's person; this device's content stays behind.
final class const EmailSignInEventSwitchConfirmed() extends EmailSignInEvent;
/// Stay anonymous.
final class const EmailSignInEventSwitchDeclined() extends EmailSignInEvent;
/// Signing in with an email address, as its sheet shows it.
sealed class const EmailSignInState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Typing the address.
final class const EmailSignInStateAddress({
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the code is being mailed.
final bool sending = false,
}) extends EmailSignInState {
@override
List<Object?> get props => [problem, sending];
}
/// Typing the code mailed to [address] for [request].
final class const EmailSignInStateCode({
required final String address,
required final String request,
/// What went wrong the last time.
final EmailSignInProblem? problem,
/// Whether the instance is being asked: about the code, or for a new one.
final bool checking = false,
/// Whether a new code was mailed on request.
final bool resent = false,
}) extends EmailSignInState {
/// This, while the instance is asked.
EmailSignInStateCode checked() =>
EmailSignInStateCode(address: address, request: request, checking: true);
/// This, once [problem] came back.
EmailSignInStateCode failed(EmailSignInProblem problem) =>
EmailSignInStateCode(
address: address,
request: request,
problem: problem,
);
@override
List<Object?> get props => [address, request, problem, checking, resent];
}
/// The address is another person's and this device holds content: switch
/// and leave it behind, or stay anonymous?
final class const EmailSignInStateConfirmSwitch({
required final String address,
required final String switchToken,
}) extends EmailSignInState {
@override
List<Object?> get props => [address, switchToken];
}
/// Switching this device to the address's person.
final class const EmailSignInStateSwitching() extends EmailSignInState;
/// The address is another account's, and the person signed in is
/// identified: nothing changed.
final class const EmailSignInStateTaken({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}
/// The address is now the person's.
final class const EmailSignInStateLinked({required final String address})
extends EmailSignInState {
@override
List<Object?> get props => [address];
}

View file

@ -0,0 +1,173 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
/// Signs an anonymous person in with GitHub, keeping everything they have,
/// or adds GitHub as a way in for someone who signs in with an email
/// address.
///
/// If the GitHub account already belongs to another person (typically their
/// account from another device), this device switches to that person: right
/// away when it holds nothing (`deviceHoldsContent`), otherwise only once
/// the person confirms that this device's content stays behind. Someone
/// identified is never switched, as their own account would be left: they
/// are told the GitHub account is another's.
class GitHubSignInBloc(
final AuthRepository _auth, {
/// Whether this device holds content of the person signed in that a
/// switch would leave behind, as the app counts it (Halkyon Learn: its
/// studysets, those waiting in Recently deleted too).
required final Future<bool> Function() _deviceHoldsContent,
}) extends Bloc<GitHubSignInEvent, GitHubSignInState> {
this : super(const GitHubSignInStateChecking()) {
on<GitHubSignInEventStarted>(_onStarted);
on<GitHubSignInEventRequested>(_onRequested);
on<GitHubSignInEventSwitchConfirmed>(_onSwitchConfirmed);
on<GitHubSignInEventSwitchDeclined>(
(event, emit) => emit(const GitHubSignInStateReady()),
);
// The sign-in in progress then ends as cancelled.
on<GitHubSignInEventCancelled>((event, emit) => _auth.cancelGitHub());
}
/// Closing the account dialog cancels a sign-in still waiting for GitHub's
/// page: nothing finishes without the dialog to show it, such as the
/// question whether to switch.
@override
Future<void> close() async {
if (state is GitHubSignInStateBusy) {
await _auth.cancelGitHub();
}
await super.close();
}
Future<void> _onStarted(
GitHubSignInEventStarted event,
Emitter<GitHubSignInState> emit,
) async {
try {
emit(
await _auth.canSignInWithGitHub()
? const GitHubSignInStateReady()
: const GitHubSignInStateUnavailable(),
);
} on Exception catch (error, st) {
// Without the instance, signing in couldn't work either.
addError(error, st);
emit(const GitHubSignInStateUnavailable());
}
}
Future<void> _onRequested(
GitHubSignInEventRequested event,
Emitter<GitHubSignInState> emit,
) async {
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
try {
switch (await _auth.linkGitHub()) {
case GitHubLinkOutcome.linked:
emit(const GitHubSignInStateLinked());
case GitHubLinkOutcome.cancelled:
emit(const GitHubSignInStateReady());
case GitHubLinkOutcome.takenByAnotherPerson when _auth.isIdentified:
emit(const GitHubSignInStateTaken());
case GitHubLinkOutcome.takenByAnotherPerson:
if (await _deviceHoldsContent()) {
emit(const GitHubSignInStateConfirmSwitch());
} else {
await _switch(emit);
}
}
} on Exception catch (error, st) {
addError(error, st);
emit(const GitHubSignInStateReady(failed: true));
}
}
Future<void> _onSwitchConfirmed(
GitHubSignInEventSwitchConfirmed event,
Emitter<GitHubSignInState> emit,
) async {
emit(GitHubSignInStateBusy(cancellable: _auth.canCancelGitHub));
try {
await _switch(emit);
} on Exception catch (error, st) {
addError(error, st);
emit(const GitHubSignInStateReady(failed: true));
}
}
// After a switch, the previous person's whole subtree goes away, this bloc
// with it, so nothing is emitted then.
Future<void> _switch(Emitter<GitHubSignInState> emit) async {
if (!await _auth.switchToGitHub()) {
emit(const GitHubSignInStateReady());
}
}
}
/// What happens to signing in with GitHub.
sealed class const GitHubSignInEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The account dialog opens: does this instance offer GitHub sign-in?
final class const GitHubSignInEventStarted() extends GitHubSignInEvent;
/// Sign in with GitHub.
final class const GitHubSignInEventRequested() extends GitHubSignInEvent;
/// Switch to the GitHub account's person; this device's content stays
/// behind.
final class const GitHubSignInEventSwitchConfirmed() extends GitHubSignInEvent;
/// Stay anonymous.
final class const GitHubSignInEventSwitchDeclined() extends GitHubSignInEvent;
/// Stop waiting for GitHub's page in the browser.
final class const GitHubSignInEventCancelled() extends GitHubSignInEvent;
/// Signing in with GitHub, as the account dialog shows it.
sealed class const GitHubSignInState() with Equatable {
@override
List<Object?> get props => const [];
}
/// Asking the instance whether it offers GitHub sign-in.
final class const GitHubSignInStateChecking() extends GitHubSignInState;
/// This instance has no GitHub sign-in, or can't be reached.
final class const GitHubSignInStateUnavailable() extends GitHubSignInState;
/// Ready to sign in with GitHub.
final class const GitHubSignInStateReady({
/// Whether the last attempt failed.
final bool failed = false,
}) extends GitHubSignInState {
@override
List<Object?> get props => [failed];
}
/// Signing in: GitHub's page is open, or the server is being asked.
final class const GitHubSignInStateBusy({
/// Whether the app offers to cancel, where no sign-in screen of the system
/// does. A cancel only stops the wait for GitHub's page.
final bool cancellable = false,
}) extends GitHubSignInState {
@override
List<Object?> get props => [cancellable];
}
/// The GitHub account belongs to another person and this device holds
/// content: switch and leave it behind, or stay anonymous?
final class const GitHubSignInStateConfirmSwitch() extends GitHubSignInState;
/// Linked: the person now signs in with GitHub.
final class const GitHubSignInStateLinked() extends GitHubSignInState;
/// The GitHub account is another account's, and the person signed in is
/// identified: nothing changed.
final class const GitHubSignInStateTaken() extends GitHubSignInState;

View file

@ -0,0 +1,249 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/data/signed_out_mark.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
/// Keeps a person signed in: follows who is signed in, signs in anonymously
/// at a first start, and signs out on request. Once signed out (by the
/// person, by deleting the account, or by the server ending the session),
/// it asks how to start rather than making an account by itself, also at
/// every start of the app until someone starts.
class SessionBloc(final AuthRepository _auth, final SignedOutMark _mark)
extends Bloc<SessionEvent, SessionState> {
this : super(const SessionStateLoading()) {
on<SessionEventStarted>(_onStarted);
on<SessionEventSignInRequested>(_onSignInRequested);
on<SessionEventStartedAnonymously>(_onStartedAnonymously);
on<SessionEventGitHubRequested>(_onGitHubRequested);
on<SessionEventEmailRequested>(_onEmailRequested);
on<SessionEventSignedOut>(
(event, emit) => emit(SessionStateSignedOut(problem: event.problem)),
);
on<SessionEventSignOutRequested>(_onSignOutRequested);
on<SessionEventDeleteRequested>(_onDeleteRequested);
}
/// Whether an email sign-in follows the anonymous start under way.
var _thenEmail = false;
Future<void> _onStarted(
SessionEventStarted event,
Emitter<SessionState> emit,
) async {
if (_auth.status is SignedOut && await _mark.read()) {
emit(const SessionStateSignedOut());
} else {
add(const SessionEventSignInRequested());
}
await emit.forEach<AuthStatus>(
_auth.statusChanges,
onData: (status) {
if (status is SignedOut) {
// Signed out, here or by the server (a revoked session): ask how
// to start, now and at the next starts.
unawaited(_mark.write(signedOut: true));
return const SessionStateSignedOut();
}
unawaited(_mark.write(signedOut: false));
return _stateOf(status, signsInWithEmail: _thenEmail);
},
);
}
Future<void> _onSignInRequested(
SessionEventSignInRequested event,
Emitter<SessionState> emit,
) async {
// A session stored on the device wins: never a second anonymous account.
if (_auth.status case final SignedIn person) {
emit(SessionStateActive(person));
return;
}
emit(const SessionStateLoading());
// Read before signing in: the status change on the way shows the app
// too, and must ask for the address as well.
final email = _thenEmail;
try {
await _auth.ensureSignedIn();
_thenEmail = false;
emit(_stateOf(_auth.status, signsInWithEmail: email));
} on Object catch (error, st) {
_thenEmail = false;
addError(error, st);
emit(const SessionStateFailed());
}
}
Future<void> _onStartedAnonymously(
SessionEventStartedAnonymously event,
Emitter<SessionState> emit,
) async {
await _mark.write(signedOut: false);
add(const SessionEventSignInRequested());
}
/// Signing in with GitHub needs no account here first: the GitHub
/// account's person, if there is one.
Future<void> _onGitHubRequested(
SessionEventGitHubRequested event,
Emitter<SessionState> emit,
) async {
emit(const SessionStateSignedOut(busy: true));
try {
if (!await _auth.switchToGitHub()) {
emit(const SessionStateSignedOut());
}
// Signed in: the status change shows the person's app.
} on GitHubSignInFailed catch (e) {
emit(
SessionStateSignedOut(
problem: e.problem == GitHubSignInProblem.unknownAccount
? SignedOutProblem.noGitHubAccount
: SignedOutProblem.failed,
),
);
} on Object catch (error, st) {
addError(error, st);
emit(const SessionStateSignedOut(problem: SignedOutProblem.failed));
}
}
/// Email codes are only taken from someone signed in: an anonymous start
/// first, then the address, which switches to its person if it has one.
Future<void> _onEmailRequested(
SessionEventEmailRequested event,
Emitter<SessionState> emit,
) async {
_thenEmail = true;
await _mark.write(signedOut: false);
add(const SessionEventSignInRequested());
}
Future<void> _onSignOutRequested(
SessionEventSignOutRequested event,
Emitter<SessionState> emit,
) async {
emit(const SessionStateLoading());
try {
// The status change to signed out then asks how to start.
await _auth.signOut();
} on Object catch (error, st) {
addError(error, st);
emit(_stateOf(_auth.status));
}
}
Future<void> _onDeleteRequested(
SessionEventDeleteRequested event,
Emitter<SessionState> emit,
) async {
emit(const SessionStateLoading());
try {
// The status change to signed out then asks how to start.
await _auth.deleteAccount();
} on Object catch (error, st) {
addError(error, st);
emit(_stateOf(_auth.status));
}
}
SessionState _stateOf(AuthStatus status, {bool signsInWithEmail = false}) =>
switch (status) {
SignedIn() => SessionStateActive(
status,
signsInWithEmail: signsInWithEmail,
),
SignedOut() => const SessionStateSignedOut(),
};
}
/// What happens to the session.
sealed class const SessionEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The app starts: follow who is signed in, and sign in anonymously if nobody
/// is.
final class const SessionEventStarted() extends SessionEvent;
/// Sign in (again): after a failed attempt, or once nobody is signed in.
final class const SessionEventSignInRequested() extends SessionEvent;
/// Start without an account, once signed out: a new anonymous one.
final class const SessionEventStartedAnonymously() extends SessionEvent;
/// Sign in with GitHub, once signed out.
final class const SessionEventGitHubRequested() extends SessionEvent;
/// Sign in with an email address, once signed out.
final class const SessionEventEmailRequested() extends SessionEvent;
/// Signed out, [problem] if any saying why the last way to start failed.
final class const SessionEventSignedOut({final SignedOutProblem? problem})
extends SessionEvent {
@override
List<Object?> get props => [problem];
}
/// The person confirmed that they want to sign out.
final class const SessionEventSignOutRequested() extends SessionEvent;
/// Delete the signed-in person with everything they have; the person has
/// confirmed it.
final class const SessionEventDeleteRequested() extends SessionEvent;
/// Whether the app has a signed-in person to work for.
sealed class const SessionState() with Equatable;
/// Signing in, or finding out who is signed in.
final class const SessionStateLoading() extends SessionState {
@override
List<Object?> get props => const [];
}
/// Signing in failed, for example without a connection; the app offers to try
/// again.
final class const SessionStateFailed() extends SessionState {
@override
List<Object?> get props => const [];
}
/// A person is signed in.
final class const SessionStateActive(
/// Who is signed in.
final SignedIn person, {
/// Whether they asked to sign in with an email address, which their app
/// asks for once it opens.
final bool signsInWithEmail = false,
}) extends SessionState {
@override
List<Object?> get props => [person, signsInWithEmail];
}
/// Nobody is signed in since signing out: the app asks how to start.
final class const SessionStateSignedOut({
/// Whether a way to start is under way.
final bool busy = false,
/// Why the last way to start failed; null for none.
final SignedOutProblem? problem,
}) extends SessionState {
@override
List<Object?> get props => [busy, problem];
}
/// Why a way to start failed.
enum SignedOutProblem() {
/// No account has the GitHub account yet: start without one, then add
/// GitHub.
noGitHubAccount,
/// Anything else, as no connection.
failed,
}

View file

@ -0,0 +1,57 @@
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// What the account card shows of signing in: whether this instance offers
/// email sign-in, how the person signs in, and the address they signed in
/// with on this device ([load] again after either changes).
class SignInWaysCubit(final AuthRepository _auth)
extends Cubit<SignInWaysState> {
this : super(const SignInWaysState());
/// Reads it all anew.
Future<void> load() async {
try {
final offersEmail = await _auth.canSignInWithEmail();
final ways = _auth.isIdentified
? await _auth.signInWays()
: SignInWays.none;
final address = await _auth.emailAddress();
if (!isClosed) {
emit(
SignInWaysState(
loaded: true,
offersEmail: offersEmail,
ways: ways,
emailAddress: address,
),
);
}
} on Exception catch (error, st) {
// Without the instance, nothing more is offered.
addError(error, st);
if (!isClosed) {
emit(const SignInWaysState(loaded: true));
}
}
}
}
/// What [SignInWaysCubit] read.
final class const SignInWaysState({
/// Whether it was read at all.
final bool loaded = false,
/// Whether this instance offers signing in with an email address.
final bool offersEmail = false,
/// How the person signs in.
final SignInWays ways = SignInWays.none,
/// The address the person signed in with on this device.
final String? emailAddress,
}) with Equatable {
@override
List<Object?> get props => [loaded, offersEmail, ways, emailAddress];
}

View file

@ -0,0 +1,65 @@
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// The boundary to the server's authentication: who is signed in, and signing
/// in and out. Tests replace it with a fake.
abstract interface class AuthGateway() {
/// Who is signed in right now.
AuthStatus get status;
/// Every later change of [status].
Stream<AuthStatus> get statusChanges;
/// Creates an anonymous account on the server and signs in with it.
Future<void> signInAnonymously();
/// Signs out on this device.
Future<void> signOut();
/// Deletes the signed-in person with everything they have, on the server
/// and so on all their devices, and forgets the session here.
Future<void> deleteAccount();
/// The client ID of this instance's GitHub OAuth app; null when GitHub
/// sign-in is off on this instance.
Future<String?> gitHubClientId();
/// Authorizes with GitHub in the browser and links the GitHub account to the
/// signed-in person: the lossless upgrade from an anonymous account.
Future<GitHubLinkOutcome> linkGitHub();
/// Authorizes with GitHub again and switches this device to the person who
/// has that GitHub account; false when the person cancelled on GitHub.
Future<bool> switchToGitHub();
/// Whether the app itself waits for GitHub's page in the browser, so that
/// only the app can cancel it (Linux, Windows). Elsewhere the system's
/// sign-in screen has its own Cancel.
bool get canCancelGitHub;
/// Stops waiting for GitHub's page in the browser: the [linkGitHub] or
/// [switchToGitHub] in progress ends as cancelled. Does nothing once
/// GitHub has answered.
Future<void> cancelGitHub();
/// Whether this instance offers signing in with an email address.
Future<bool> offersEmailSignIn();
/// Mails a code to [address], worded in [language] (`en`, `de`), and
/// returns the request it answers. Throws [EmailSignInFailed].
Future<String> sendEmailCode(String address, {required String language});
/// Accepts [code] for [request]: the address becomes the signed-in
/// person's, the lossless upgrade from an anonymous account, unless it is
/// another person's. Throws [EmailSignInFailed].
Future<EmailCodeOutcome> verifyEmailCode(String request, String code);
/// Switches this device to the person [switchToken] signs in as, and
/// returns them (their `AuthUser` id). Throws [EmailSignInFailed].
Future<String> switchToEmail(String switchToken);
/// How the signed-in person signs in.
Future<SignInWays> signInWays();
}

View file

@ -0,0 +1,114 @@
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/sign_in_addresses.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
/// Who is signed in, and signing in and out, as the features use it.
class AuthRepository(
final AuthGateway _gateway,
final SignInAddresses _addresses,
) {
/// Who is signed in right now.
AuthStatus get status => _gateway.status;
/// Every later change of [status].
Stream<AuthStatus> get statusChanges => _gateway.statusChanges;
/// Whether the signed-in person has an identity, as GitHub or an email
/// address.
bool get isIdentified => switch (_gateway.status) {
SignedIn(:final isIdentified) => isIdentified,
SignedOut() => false,
};
/// Signs in anonymously unless someone is signed in already, so the app
/// works from the first launch without any sign-up.
Future<void> ensureSignedIn() async {
if (_gateway.status is SignedOut) {
await _gateway.signInAnonymously();
}
}
/// Signs out on this device. An anonymous account can never be signed into
/// again, so signing out of one deletes it with its progress (decided
/// 2026-09-30); the UI warns first.
Future<void> signOut() => switch (_gateway.status) {
SignedIn(isIdentified: false) => _gateway.deleteAccount(),
_ => _gateway.signOut(),
};
/// Deletes the signed-in person with everything they have, on the server
/// and all their devices; the UI asks first.
Future<void> deleteAccount() => _gateway.deleteAccount();
/// Whether this instance offers signing in with GitHub.
Future<bool> canSignInWithGitHub() async =>
await _gateway.gitHubClientId() != null;
/// Links a GitHub account to the signed-in person; the person, and all
/// their data, stay the same.
Future<GitHubLinkOutcome> linkGitHub() => _gateway.linkGitHub();
/// Switches this device to the person who has the GitHub account. The
/// anonymous account this device leaves behind can't be signed in again.
Future<bool> switchToGitHub() => _gateway.switchToGitHub();
/// Whether the app offers to cancel a GitHub sign-in in progress, where no
/// sign-in screen of the system does.
bool get canCancelGitHub => _gateway.canCancelGitHub;
/// Cancels the GitHub sign-in in progress while GitHub's page is open.
Future<void> cancelGitHub() => _gateway.cancelGitHub();
/// Whether this instance offers signing in with an email address.
Future<bool> canSignInWithEmail() => _gateway.offersEmailSignIn();
/// Mails a code to [address], worded in [language]; returns the request
/// it answers. Throws `EmailSignInFailed`.
Future<String> sendEmailCode(String address, {required String language}) =>
_gateway.sendEmailCode(address, language: language);
/// Accepts [code] for [request], mailed to [address]: the address becomes
/// the signed-in person's, who keeps everything (and this device
/// remembers it, to show), unless it is another person's. Throws
/// `EmailSignInFailed`.
Future<EmailCodeOutcome> verifyEmailCode(
String request,
String code, {
required String address,
}) async {
final person = _gateway.status;
final outcome = await _gateway.verifyEmailCode(request, code);
if ((outcome, person) case (
EmailLinked(),
SignedIn(:final authUserId),
)) {
await _addresses.remember(authUserId, address);
}
return outcome;
}
/// Switches this device to the person who signs in with [address], whom
/// [switchToken] names. An anonymous account left behind can't be signed
/// into again, and goes. Throws `EmailSignInFailed`.
Future<void> switchToEmail(
String switchToken, {
required String address,
}) async {
final person = await _gateway.switchToEmail(switchToken);
// Into the folder of the person switched to, which stays.
await _addresses.remember(person, address);
}
/// How the signed-in person signs in.
Future<SignInWays> signInWays() => _gateway.signInWays();
/// The email address the signed-in person signed in with on this device;
/// null when they didn't, or did elsewhere.
Future<String?> emailAddress() async => switch (_gateway.status) {
SignedIn(:final authUserId) => await _addresses.of(authUserId),
SignedOut() => null,
};
}

View file

@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_to_front/window_to_front.dart';
/// The browser half of signing in with GitHub on Linux and Windows: opens
/// GitHub's page in the system browser and waits on a loopback address until
/// the browser comes back (RFC 8252, section 7.3).
///
/// It takes the place of flutter_web_auth_2's own implementation, which the
/// package's front ends whenever the app is resumed. That suits mobile, where
/// coming back to the app without finishing means giving up, but on the
/// desktop the app's window regaining focus resumes it, and the browser came
/// back to a closed port. Remove this class once the package no longer ends
/// the sign-in on Linux and Windows then.
///
/// The wait ends when the browser comes back, on [cancel], when another
/// sign-in starts, or after [_timeout].
class LoopbackWebAuth({
/// The app's name, as the browser's page says to go back to it.
final String appName = 'Halkyon',
final Future<bool> Function(Uri url) _launch = launchUrl,
final Future<void> Function() _bringToFront = WindowToFront.activate,
final Duration _timeout = const Duration(minutes: 5),
}) extends FlutterWebAuth2Platform {
HttpServer? _server;
Completer<String>? _pending;
/// Opens [url] in the browser and returns the URL the browser comes back
/// to. [callbackUrlScheme] is the whole redirect URI, on 127.0.0.1 with a
/// port; only its path ends the wait. [options] are the package's and
/// don't apply.
@override
Future<String> authenticate({
required String url,
required String callbackUrlScheme,
required Map<String, dynamic> options,
}) async {
final callback = Uri.parse(callbackUrlScheme);
if (callback.scheme != 'http' ||
callback.host != InternetAddress.loopbackIPv4.address ||
!callback.hasPort ||
callback.path.isEmpty) {
throw ArgumentError.value(
callbackUrlScheme,
'callbackUrlScheme',
'must be a path on http://127.0.0.1 with a port',
);
}
await cancel();
// The loopback address only: nothing but this device can answer.
final server = _server = await HttpServer.bind(
InternetAddress.loopbackIPv4,
callback.port,
);
final pending = _pending = Completer<String>()..future.ignore();
server.listen((request) => _answer(request, callback.path, pending));
try {
if (!await _launch(Uri.parse(url))) {
throw PlatformException(
code: 'FAILED',
message: "Couldn't open the browser.",
);
}
final result = await pending.future.timeout(_timeout);
try {
await _bringToFront();
} on Exception catch (error, st) {
// The sign-in worked; the person switches back by themselves.
log(
"Couldn't bring the window to the front",
name: 'Auth',
error: error,
stackTrace: st,
);
}
return result;
} finally {
await server.close();
if (identical(_server, server)) {
_server = null;
_pending = null;
}
}
}
/// Does nothing: the package's front calls it whenever the app is resumed,
/// which on the desktop includes the window regaining focus. [cancel] ends
/// a sign-in instead.
@override
Future<void> clearAllDanglingCalls() async {}
/// Stops waiting: the sign-in in progress ends as cancelled, the way the
/// package reports it. Does nothing when none is in progress.
Future<void> cancel() async {
final pending = _pending;
final server = _server;
_pending = null;
_server = null;
if (pending != null && !pending.isCompleted) {
pending.completeError(
PlatformException(code: 'CANCELED', message: 'User canceled login'),
);
}
await server?.close(force: true);
}
/// Answers the browser: the first request for the callback's path ends the
/// wait, anything else is not found.
Future<void> _answer(
HttpRequest request,
String callbackPath,
Completer<String> pending,
) async {
final response = request.response;
if (request.uri.path == callbackPath && !pending.isCompleted) {
pending.complete(request.requestedUri.toString());
response
..headers.contentType = ContentType.html
..write(_landingPage(appName));
} else {
response.statusCode = HttpStatus.notFound;
}
try {
await response.close();
} on IOException {
// The browser went away before the answer; nothing is left to tell it.
}
}
}
/// What the browser shows once it has come back to [app].
String _landingPage(String app) {
final name = const HtmlEscape().convert(app);
return '''
<!DOCTYPE html>
<html lang="en">
<meta charset="utf-8">
<title>$name</title>
<p>You can close this tab and return to $name.</p>
</html>
''';
}

View file

@ -0,0 +1,365 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:flutter_web_auth_2_platform_interface/flutter_web_auth_2_platform_interface.dart';
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
import 'package:halkyon_app/src/auth/data/loopback_web_auth.dart';
import 'package:halkyon_app/src/auth/model/auth_status.dart';
import 'package:halkyon_app/src/auth/model/email_sign_in.dart';
import 'package:halkyon_app/src/auth/model/github_link_outcome.dart';
import 'package:halkyon_app/src/auth/model/github_sign_in.dart';
import 'package:halkyon_app/src/auth/model/sign_in_ways.dart';
import 'package:halkyon_app/src/base/base_client.dart';
import 'package:halkyon_base_client/halkyon_base_client.dart'
show
EmailSignInRefusal,
EmailSignInRefusedException,
GitHubSignInRefusal,
GitHubSignInRefusedException;
import 'package:halkyon_base_client/halkyon_base_client.dart' as base_client;
import 'package:http/http.dart' as http;
import 'package:serverpod_auth_idp_flutter/serverpod_auth_idp_flutter.dart';
/// [AuthGateway] on top of an app's Serverpod client and its session
/// manager, which keeps the session on the device (secure storage). Signing
/// in and the account are the instance's halkyon_base's, called through
/// [_base], the module's endpoints on that client
/// (`client.modules.base`).
class ServerpodAuthGateway(
final ServerpodClientShared _client, {
required final base_client.Caller _base,
/// The app's own URL scheme, which GitHub sends the browser back to on
/// Android, iOS and macOS (`app.halkyon.learn`), registered with the
/// system for the app and in the instance's GitHub OAuth app.
required final String callbackScheme,
/// The port on 127.0.0.1 the app listens on for GitHub's answer on Linux
/// and Windows.
final int loopbackPort = 43823,
/// The app's name, as the browser's page says to go back to it.
required final String appName,
/// What the clients it makes for itself send through (to sign in as
/// another person, and to delete an account left behind); tests give a
/// fake one.
final http.Client Function()? _httpClient,
}) implements AuthGateway {
this {
_client.auth.authInfoListenable.addListener(_onAuthInfoChanged);
}
final _changes = StreamController<AuthStatus>.broadcast();
/// The scope a person gets once they have an identity.
static const _identifiedScope = 'identified';
/// Where GitHub sends the browser back after authorizing: the app's own
/// scheme on Android, iOS and macOS; on Linux and Windows a loopback address
/// the app listens on meanwhile (GitHub accepts any port there, so the
/// OAuth app registers `http://127.0.0.1/github-callback`). There,
/// [LoopbackWebAuth] takes the whole address, to answer only its path.
late final _gitHubCallback = Platform.isLinux || Platform.isWindows
? (
redirectUri: 'http://127.0.0.1:$loopbackPort/github-callback',
urlScheme: 'http://127.0.0.1:$loopbackPort/github-callback',
)
: (
redirectUri: '$callbackScheme://github-callback',
urlScheme: callbackScheme,
);
/// The browser half of signing in with GitHub on Linux and Windows, in
/// place of flutter_web_auth_2's own, which stops waiting whenever the
/// window regains focus. Elsewhere, the package's.
late final _loopback = Platform.isLinux || Platform.isWindows
? LoopbackWebAuth(appName: appName)
: null;
/// Whether the person cancelled the GitHub sign-in in progress.
var _gitHubCancelled = false;
@override
AuthStatus get status => switch (_client.auth.authInfo) {
null => const SignedOut(),
final authInfo => SignedIn(
authUserId: authInfo.authUserId.toString(),
isIdentified: authInfo.scopeNames.contains(_identifiedScope),
),
};
@override
Stream<AuthStatus> get statusChanges => _changes.stream;
@override
Future<void> signInAnonymously() async {
final authSuccess = await _base.anonymousIdp.login();
await _client.auth.updateSignedInUser(authSuccess);
}
@override
Future<void> signOut() async {
await _client.auth.signOutDevice();
}
@override
Future<void> deleteAccount() async {
await _base.account.delete();
// The server ended every session of the person; forget this device's
// copy.
await _client.auth.updateSignedInUser(null);
}
@override
Future<String?> gitHubClientId() async =>
(await _base.instance.basics()).gitHubClientId;
@override
Future<GitHubLinkOutcome> linkGitHub() async {
final authorization = await _authorizeWithGitHub();
if (authorization == null) {
return GitHubLinkOutcome.cancelled;
}
try {
await _base.gitHubUpgrade.upgrade(
code: authorization.code,
codeVerifier: authorization.codeVerifier,
redirectUri: authorization.redirectUri,
);
} on GitHubSignInRefusedException catch (error) {
if (error.reason != GitHubSignInRefusal.takenByAnotherPerson) {
throw _gitHubFailure(error);
}
return GitHubLinkOutcome.takenByAnotherPerson;
}
await _becomeIdentified();
return GitHubLinkOutcome.linked;
}
/// The server gave the person's sessions the identified scope; the copy of
/// this device's session follows.
Future<void> _becomeIdentified() async {
if (_client.auth.authInfo case final authInfo?) {
await _client.auth.updateSignedInUser(
authInfo.copyWith(
scopeNames: {...authInfo.scopeNames, _identifiedScope},
),
);
}
}
@override
Future<bool> switchToGitHub() async {
final authorization = await _authorizeWithGitHub();
if (authorization == null) {
return false;
}
// Serverpod issues no session for another person inside a signed-in
// call, so a client without a session asks for it.
final signedOut = _clientOfItsOwn();
final AuthSuccess person;
try {
person = await signedOut.modules.gitHubSignIn.signIn(
code: authorization.code,
codeVerifier: authorization.codeVerifier,
redirectUri: authorization.redirectUri,
);
} on GitHubSignInRefusedException catch (error) {
throw _gitHubFailure(error);
} finally {
signedOut.close();
}
await _switchTo(person);
return true;
}
/// Switches this device to [person]. Swapping directly means the app never
/// passes through "signed out", which would start yet another anonymous
/// account. An anonymous account left behind is deleted, as nobody could
/// sign into it again; an identified one stays, to be signed into again.
Future<void> _switchTo(AuthSuccess person) async {
final leftBehind = _client.auth.authInfo;
await _client.auth.updateSignedInUser(person);
if (leftBehind != null &&
!leftBehind.scopeNames.contains(_identifiedScope)) {
await _deleteLeftBehind(leftBehind);
}
}
@override
bool get canCancelGitHub => _loopback != null;
@override
Future<void> cancelGitHub() async {
_gitHubCancelled = true;
await _loopback?.cancel();
}
/// GitHub's authorization in the browser, or null when the person
/// cancelled it.
Future<GitHubSignInResult?> _authorizeWithGitHub() async {
_gitHubCancelled = false;
final clientId = await gitHubClientId();
if (clientId == null) {
throw StateError('This instance offers no GitHub sign-in.');
}
await GitHubSignInService.instance.ensureInitialized(
clientId: clientId,
redirectUri: _gitHubCallback.redirectUri,
callbackUrlScheme: _gitHubCallback.urlScheme,
// On Linux and Windows the system browser and a loopback address, not
// an embedded webview, as the OAuth best practice for native apps
// (RFC 8252) asks.
useWebview: false,
);
if (_loopback case final loopback?) {
// Here, after the package has put its own in place at startup.
FlutterWebAuth2Platform.instance = loopback;
}
if (_gitHubCancelled) {
return null;
}
try {
return await GitHubSignInService.instance.signIn();
} on OAuth2PkceUserCancelledException {
return null;
} on OAuth2PkceException {
// Serverpod (checked with v4.0.3) reports a cancelled browser round
// trip as an unknown error, so a cancel is known by having asked for
// it.
if (_gitHubCancelled) {
return null;
}
rethrow;
}
}
@override
Future<bool> offersEmailSignIn() async =>
(await _base.instance.basics()).emailSignIn;
@override
Future<String> sendEmailCode(
String address, {
required String language,
}) => _asEmailFailure(() async {
final request = await _base.emailCode.sendCode(
address: address,
language: language,
);
return request.uuid;
});
@override
Future<EmailCodeOutcome> verifyEmailCode(String request, String code) =>
_asEmailFailure(() async {
final accepted = await _base.emailCode.verifyCode(
request: UuidValue.withValidation(request),
code: code,
);
if (accepted.switchToken case final token?) {
return EmailTakenByAnotherPerson(token);
}
await _becomeIdentified();
return const EmailLinked();
});
@override
Future<String> switchToEmail(String switchToken) => _asEmailFailure(() async {
// As switchToGitHub: without a session.
final signedOut = _clientOfItsOwn();
final AuthSuccess person;
try {
person = await signedOut.modules.emailSignIn.signIn(
switchToken: switchToken,
);
} finally {
signedOut.close();
}
await _switchTo(person);
return person.authUserId.uuid;
});
@override
Future<SignInWays> signInWays() async {
final ways = await _base.account.signInWays();
return SignInWays(gitHub: ways.gitHub, email: ways.email);
}
/// The instance's refusal of a GitHub sign-in as [GitHubSignInFailed].
static GitHubSignInFailed _gitHubFailure(GitHubSignInRefusedException e) =>
GitHubSignInFailed(
e.reason == GitHubSignInRefusal.unknownAccount
? GitHubSignInProblem.unknownAccount
: GitHubSignInProblem.refused,
);
/// What [action] gives; the instance's refusals as [EmailSignInFailed].
Future<T> _asEmailFailure<T>(Future<T> Function() action) async {
try {
return await action();
} on EmailSignInRefusedException catch (refusal) {
throw EmailSignInFailed(switch (refusal.reason) {
EmailSignInRefusal.unavailable => EmailSignInProblem.unavailable,
EmailSignInRefusal.notAnAddress => EmailSignInProblem.notAnAddress,
EmailSignInRefusal.tooManyCodes => EmailSignInProblem.tooManyCodes,
EmailSignInRefusal.wrongCode => EmailSignInProblem.wrongCode,
EmailSignInRefusal.tooManyTries => EmailSignInProblem.tooManyTries,
EmailSignInRefusal.codeExpired => EmailSignInProblem.codeExpired,
EmailSignInRefusal.otherAddressLinked =>
EmailSignInProblem.otherAddressLinked,
EmailSignInRefusal.mailFailed => EmailSignInProblem.mailFailed,
});
}
}
/// Deletes the anonymous account this device leaves behind when switching:
/// nobody could ever sign into it again. It signs in as that account from
/// a client of its own; a failure only leaves the account to the pruning.
Future<void> _deleteLeftBehind(AuthSuccess anonymous) async {
final sessionManager = FlutterAuthSessionManager(
storage: _MemoryAuthStorage(),
);
final client = _clientOfItsOwn()..authSessionManager = sessionManager;
try {
await sessionManager.updateSignedInUser(anonymous);
await client.modules.account.delete();
} on Exception catch (error, st) {
log(
'Deleting the anonymous account left behind failed',
name: 'Auth',
error: error,
stackTrace: st,
);
} finally {
client.close();
}
}
/// A client of the instance's halkyon_base, holding no session.
BaseClient _clientOfItsOwn() =>
BaseClient(_client.host, httpClientOverride: _httpClient?.call());
/// Stops listening; the owner calls it when the app shuts down.
Future<void> dispose() async {
_client.auth.authInfoListenable.removeListener(_onAuthInfoChanged);
await _changes.close();
}
void _onAuthInfoChanged() => _changes.add(status);
}
/// Keeps one session in memory, for a client used once.
final class _MemoryAuthStorage() implements ClientAuthSuccessStorage {
AuthSuccess? _session;
@override
Future<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}

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