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:
parent
af1de799f4
commit
e23cc77f7c
64 changed files with 10761 additions and 10 deletions
70
packages/halkyon_app/lib/halkyon_app.dart
Normal file
70
packages/halkyon_app/lib/halkyon_app.dart
Normal 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';
|
||||
78
packages/halkyon_app/lib/l10n/halkyon_app_de.arb
Normal file
78
packages/halkyon_app/lib/l10n/halkyon_app_de.arb
Normal 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"
|
||||
}
|
||||
201
packages/halkyon_app/lib/l10n/halkyon_app_en.arb
Normal file
201
packages/halkyon_app/lib/l10n/halkyon_app_en.arb
Normal 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"
|
||||
}
|
||||
3
packages/halkyon_app/lib/l10n/halkyon_app_en_GB.arb
Normal file
3
packages/halkyon_app/lib/l10n/halkyon_app_en_GB.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_GB"
|
||||
}
|
||||
3
packages/halkyon_app/lib/l10n/halkyon_app_en_US.arb
Normal file
3
packages/halkyon_app/lib/l10n/halkyon_app_en_US.arb
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"@@locale": "en_US"
|
||||
}
|
||||
605
packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart
Normal file
605
packages/halkyon_app/lib/l10n/halkyon_app_localizations.dart
Normal 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.',
|
||||
);
|
||||
}
|
||||
290
packages/halkyon_app/lib/l10n/halkyon_app_localizations_de.dart
Normal file
290
packages/halkyon_app/lib/l10n/halkyon_app_localizations_de.dart
Normal 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';
|
||||
}
|
||||
297
packages/halkyon_app/lib/l10n/halkyon_app_localizations_en.dart
Normal file
297
packages/halkyon_app/lib/l10n/halkyon_app_localizations_en.dart
Normal 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');
|
||||
}
|
||||
96
packages/halkyon_app/lib/src/app/app_frame.dart
Normal file
96
packages/halkyon_app/lib/src/app/app_frame.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
57
packages/halkyon_app/lib/src/app/app_scope.dart
Normal file
57
packages/halkyon_app/lib/src/app/app_scope.dart
Normal 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;
|
||||
}
|
||||
184
packages/halkyon_app/lib/src/app/instance_gate.dart
Normal file
184
packages/halkyon_app/lib/src/app/instance_gate.dart
Normal 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
packages/halkyon_app/lib/src/app/person_scope.dart
Normal file
91
packages/halkyon_app/lib/src/app/person_scope.dart
Normal 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;
|
||||
}
|
||||
258
packages/halkyon_app/lib/src/auth/bloc/email_sign_in_bloc.dart
Normal file
258
packages/halkyon_app/lib/src/auth/bloc/email_sign_in_bloc.dart
Normal 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];
|
||||
}
|
||||
173
packages/halkyon_app/lib/src/auth/bloc/github_sign_in_bloc.dart
Normal file
173
packages/halkyon_app/lib/src/auth/bloc/github_sign_in_bloc.dart
Normal 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;
|
||||
249
packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart
Normal file
249
packages/halkyon_app/lib/src/auth/bloc/session_bloc.dart
Normal 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,
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
65
packages/halkyon_app/lib/src/auth/data/auth_gateway.dart
Normal file
65
packages/halkyon_app/lib/src/auth/data/auth_gateway.dart
Normal 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();
|
||||
}
|
||||
114
packages/halkyon_app/lib/src/auth/data/auth_repository.dart
Normal file
114
packages/halkyon_app/lib/src/auth/data/auth_repository.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
149
packages/halkyon_app/lib/src/auth/data/loopback_web_auth.dart
Normal file
149
packages/halkyon_app/lib/src/auth/data/loopback_web_auth.dart
Normal 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>
|
||||
''';
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue