refactor(halkyon_app): the account section, About, the language and countries' names, shared
What Learn and Todo each held now comes from halkyon_app: - AccountTiles: whose account on which instance, switching, signing out (asking first for an anonymous account, as it deletes it) and deleting it (asking first), the app's own words passed in. - showAppAbout: the app's name, version, icon and legalese as it gives them, the source of what the instance runs (AppDependencies gains sourceCodeUrl), the app's own parts, the licence list. - resolveAppLocale: the first preferred language the app speaks, never by country alone, British English otherwise. - AssetCountryNames, the default of InstanceGate and InstanceScope: Unicode CLDR 48.2's territory names in English and German (tool/country_names.py, 7.6 KB), Hong Kong, Macao, Myanmar and Palestine by their short names as Learn's world map has them, so Todo says "Germany (EU)" where it said "DE (EU)". Learn's texts read as before in every language; its About bloc and gateway, its country names from the world map and the keys only the moved code used are gone, as are Todo's copies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
65c5cd740d
commit
87500b7c6b
24 changed files with 1001 additions and 834 deletions
|
|
@ -25,16 +25,29 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
(ISO 3166-2, `US-CA`) as its operator says, "Germany (EU)" for a member of
|
||||
the European Union (`europeanUnionMembers`) and "California, United States"
|
||||
for a subdivision, with the most specific flag there is (a few subdivisions
|
||||
have their own, such as Scotland; else the country's). Countries are named
|
||||
by the app's `CountryNames`, subdivisions by Unicode CLDR's names in English
|
||||
and German, which come with the package (`AssetSubdivisionNames`; the
|
||||
package's `tool/subdivision_names.py` writes them).
|
||||
have their own, such as Scotland; else the country's). Countries and
|
||||
subdivisions are named by Unicode CLDR's names in English and German, which
|
||||
come with the package (`AssetCountryNames`, `AssetSubdivisionNames`; the
|
||||
package's `tool/country_names.py` and `tool/subdivision_names.py` write
|
||||
them, from one release); tests give names of their own (`CountryNames`,
|
||||
`SubdivisionNames`).
|
||||
- **Gates** (`lib/src/app/`): `InstanceGate` opens the app's dependencies on
|
||||
the instance in use (`AppDependencies`, handed down by `AppScope`), anew on
|
||||
a switch, the previous ones closed first; `SessionGate` keeps a person
|
||||
signed in, signing in anonymously at a first start and asking how to start
|
||||
once signed out (`StartScreen`); `PersonScope` opens each person's own
|
||||
dependencies and closes them when the person goes.
|
||||
dependencies and closes them when the person goes. `resolveAppLocale`
|
||||
picks the app's language as every Halkyon app does: the first preferred
|
||||
language it speaks, never by country alone, British English otherwise.
|
||||
- **About** (`showAppAbout`): the app's name (its frame's), its version,
|
||||
icon and legalese where it gives them, where the source of what the
|
||||
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
|
||||
both licences require offering it), the app's own parts (its credits), and
|
||||
the licence list.
|
||||
- **The account** (`AccountTiles`): whose account on which instance,
|
||||
switching to the account on another, signing out (which of an anonymous
|
||||
account deletes it, so asks first) and deleting it (after asking), for an
|
||||
app's settings, in the app's words where only it can say them.
|
||||
- **Signing in** (`lib/src/auth/`): `AuthRepository` on an `AuthGateway`;
|
||||
`ServerpodAuthGateway` signs in anonymously, with GitHub (the system
|
||||
browser and a loopback address on Linux and Windows, `LoopbackWebAuth`) and
|
||||
|
|
@ -70,12 +83,13 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
delegates, beside `HalkyonUiLocalizations.delegate` and material_ui's
|
||||
`GlobalMaterialLocalizations.delegates`.
|
||||
4. **The frame** (`AppFrame`): the app's name and wordmark word, its icon, an
|
||||
example address, its theme, delegates, languages and launch screen, and the
|
||||
two sentences only it can say: what the first start's instance keeps of
|
||||
the person's, and what a switch to another account leaves behind.
|
||||
example address, its theme, delegates, languages (`resolveAppLocale` as
|
||||
its resolution) and launch screen, and the three sentences only it can
|
||||
say: what the first start's instance keeps of the person's, what a switch
|
||||
to another account leaves behind, and where GitHub is added later.
|
||||
5. **The dependencies**: the app's composition root implements
|
||||
`AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `openPerson`,
|
||||
`dispose`), its person's dependencies `Disposable`. Its `auth` is an
|
||||
`AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `sourceCodeUrl`,
|
||||
`openPerson`, `dispose`), its person's dependencies `Disposable`. Its `auth` is an
|
||||
`AuthRepository` on `ServerpodAuthGateway(client, base:
|
||||
client.modules.base, callbackScheme: …, appName: …)`; the scheme is the
|
||||
app's own (`app.halkyon.learn`), registered with each platform and in the
|
||||
|
|
@ -88,7 +102,6 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
frame: frame,
|
||||
choice: choice,
|
||||
checker: ServerpodInstanceChecker(app: 'todo'),
|
||||
countryNames: …,
|
||||
readLook: …, // the device's theme mode and language
|
||||
open: Dependencies.create,
|
||||
app: SessionGate(
|
||||
|
|
|
|||
13
packages/halkyon_app/assets/countries/ATTRIBUTION.md
Normal file
13
packages/halkyon_app/assets/countries/ATTRIBUTION.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Countries' names
|
||||
|
||||
`countries.json` holds the names of countries and territories (ISO 3166-1 alpha-2
|
||||
codes, `DE`) in English and German, from the Unicode Common Locale Data Repository
|
||||
(CLDR, <https://cldr.unicode.org/>), release 48.2 (tag `release-48-2`,
|
||||
`common/main/en.xml` and `de.xml`, their `territories`), converted by the package's
|
||||
`tool/country_names.py`: English whole, German where it differs, variants left out
|
||||
but CLDR's short names of Hong Kong, Macao, Myanmar and Palestine, as Learn's world
|
||||
map has them.
|
||||
|
||||
CLDR is under the Unicode License v3, whose notice, verbatim from that release, is in
|
||||
`../subdivisions/LICENSE-CLDR.txt` and in the app's licence list
|
||||
(`registerHalkyonAppLicenses`).
|
||||
1
packages/halkyon_app/assets/countries/countries.json
Normal file
1
packages/halkyon_app/assets/countries/countries.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -3,7 +3,7 @@
|
|||
`subdivisions.json` holds the names of states, regions and provinces (ISO 3166-2
|
||||
codes, `US-CA`) in English and German, from the Unicode Common Locale Data
|
||||
Repository (CLDR, <https://cldr.unicode.org/>), release 48.2 (tag `release-48-2`,
|
||||
`common/subdivisions/en.xml` and `de.xml`), converted by the workspace's
|
||||
`common/subdivisions/en.xml` and `de.xml`), converted by the package's
|
||||
`tool/subdivision_names.py`: English whole, German where it differs, variants left
|
||||
out.
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
/// 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.
|
||||
/// - **The account and About**: `AccountTiles` for an app's settings,
|
||||
/// `showAppAbout` with the source of what the instance runs, and
|
||||
/// `resolveAppLocale`, the language every Halkyon app picks.
|
||||
/// - **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.
|
||||
|
|
@ -20,7 +23,9 @@
|
|||
library;
|
||||
|
||||
export 'l10n/halkyon_app_localizations.dart';
|
||||
export 'src/app/about.dart';
|
||||
export 'src/app/app_frame.dart' show AppFrame, AppFrameScope, LookSettings;
|
||||
export 'src/app/app_locale.dart';
|
||||
export 'src/app/app_scope.dart';
|
||||
export 'src/app/instance_gate.dart';
|
||||
export 'src/app/person_scope.dart';
|
||||
|
|
@ -39,6 +44,7 @@ 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/account_tiles.dart';
|
||||
export 'src/auth/widget/email_sign_in_sheet.dart';
|
||||
export 'src/auth/widget/session_gate.dart';
|
||||
export 'src/auth/widget/start_screen.dart';
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@
|
|||
"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",
|
||||
"accountSignedInOn": "Angemeldet auf {instance}",
|
||||
"accountAnonymousOn": "Anonym auf {instance}",
|
||||
"accountSwitch": "Konto wechseln",
|
||||
"accountSignOut": "Abmelden",
|
||||
"accountSignOutTitle": "Abmelden?",
|
||||
"accountDelete": "Konto löschen",
|
||||
"accountDeleteTitle": "Dein Konto löschen?",
|
||||
"aboutSourceCode": "Der Quellcode dessen, was diese Instanz ausführt:",
|
||||
"aboutSourceUnreachable": "Diese Instanz war nicht erreichbar, um nach ihrem Quellcode zu fragen.",
|
||||
"sessionUnreachable": "Der Server war nicht erreichbar.",
|
||||
"personDataFailed": "Die Daten dieses Geräts konnten nicht geöffnet werden.",
|
||||
"startSignedOut": "Abgemeldet",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,29 @@
|
|||
}
|
||||
},
|
||||
"accountRemoveConfirm": "Remove",
|
||||
"accountSignedInOn": "Signed in on {instance}",
|
||||
"@accountSignedInOn": {
|
||||
"placeholders": {
|
||||
"instance": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountAnonymousOn": "Anonymous on {instance}",
|
||||
"@accountAnonymousOn": {
|
||||
"placeholders": {
|
||||
"instance": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"accountSwitch": "Switch account",
|
||||
"accountSignOut": "Sign out",
|
||||
"accountSignOutTitle": "Sign out?",
|
||||
"accountDelete": "Delete account",
|
||||
"accountDeleteTitle": "Delete your account?",
|
||||
"aboutSourceCode": "The source of what this instance runs:",
|
||||
"aboutSourceUnreachable": "Couldn't reach this instance to ask where its source is.",
|
||||
"sessionUnreachable": "Couldn't reach the server.",
|
||||
"personDataFailed": "Couldn't open this device's data.",
|
||||
"startSignedOut": "Signed out",
|
||||
|
|
|
|||
|
|
@ -271,6 +271,60 @@ abstract class HalkyonAppLocalizations {
|
|||
/// **'Remove'**
|
||||
String get accountRemoveConfirm;
|
||||
|
||||
/// No description provided for @accountSignedInOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Signed in on {instance}'**
|
||||
String accountSignedInOn(String instance);
|
||||
|
||||
/// No description provided for @accountAnonymousOn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Anonymous on {instance}'**
|
||||
String accountAnonymousOn(String instance);
|
||||
|
||||
/// No description provided for @accountSwitch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Switch account'**
|
||||
String get accountSwitch;
|
||||
|
||||
/// No description provided for @accountSignOut.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign out'**
|
||||
String get accountSignOut;
|
||||
|
||||
/// No description provided for @accountSignOutTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sign out?'**
|
||||
String get accountSignOutTitle;
|
||||
|
||||
/// No description provided for @accountDelete.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete account'**
|
||||
String get accountDelete;
|
||||
|
||||
/// No description provided for @accountDeleteTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete your account?'**
|
||||
String get accountDeleteTitle;
|
||||
|
||||
/// No description provided for @aboutSourceCode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The source of what this instance runs:'**
|
||||
String get aboutSourceCode;
|
||||
|
||||
/// No description provided for @aboutSourceUnreachable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t reach this instance to ask where its source is.'**
|
||||
String get aboutSourceUnreachable;
|
||||
|
||||
/// No description provided for @sessionUnreachable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -114,6 +114,39 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get accountRemoveConfirm => 'Entfernen';
|
||||
|
||||
@override
|
||||
String accountSignedInOn(String instance) {
|
||||
return 'Angemeldet auf $instance';
|
||||
}
|
||||
|
||||
@override
|
||||
String accountAnonymousOn(String instance) {
|
||||
return 'Anonym auf $instance';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountSwitch => 'Konto wechseln';
|
||||
|
||||
@override
|
||||
String get accountSignOut => 'Abmelden';
|
||||
|
||||
@override
|
||||
String get accountSignOutTitle => 'Abmelden?';
|
||||
|
||||
@override
|
||||
String get accountDelete => 'Konto löschen';
|
||||
|
||||
@override
|
||||
String get accountDeleteTitle => 'Dein Konto löschen?';
|
||||
|
||||
@override
|
||||
String get aboutSourceCode =>
|
||||
'Der Quellcode dessen, was diese Instanz ausführt:';
|
||||
|
||||
@override
|
||||
String get aboutSourceUnreachable =>
|
||||
'Diese Instanz war nicht erreichbar, um nach ihrem Quellcode zu fragen.';
|
||||
|
||||
@override
|
||||
String get sessionUnreachable => 'Der Server war nicht erreichbar.';
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,38 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
@override
|
||||
String get accountRemoveConfirm => 'Remove';
|
||||
|
||||
@override
|
||||
String accountSignedInOn(String instance) {
|
||||
return 'Signed in on $instance';
|
||||
}
|
||||
|
||||
@override
|
||||
String accountAnonymousOn(String instance) {
|
||||
return 'Anonymous on $instance';
|
||||
}
|
||||
|
||||
@override
|
||||
String get accountSwitch => 'Switch account';
|
||||
|
||||
@override
|
||||
String get accountSignOut => 'Sign out';
|
||||
|
||||
@override
|
||||
String get accountSignOutTitle => 'Sign out?';
|
||||
|
||||
@override
|
||||
String get accountDelete => 'Delete account';
|
||||
|
||||
@override
|
||||
String get accountDeleteTitle => 'Delete your account?';
|
||||
|
||||
@override
|
||||
String get aboutSourceCode => 'The source of what this instance runs:';
|
||||
|
||||
@override
|
||||
String get aboutSourceUnreachable =>
|
||||
'Couldn\'t reach this instance to ask where its source is.';
|
||||
|
||||
@override
|
||||
String get sessionUnreachable => 'Couldn\'t reach the server.';
|
||||
|
||||
|
|
|
|||
75
packages/halkyon_app/lib/src/app/about.dart
Normal file
75
packages/halkyon_app/lib/src/app/about.dart
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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:material_ui/material_ui.dart';
|
||||
|
||||
/// Shows what the app is: its name (the frame's), and as the app gives them
|
||||
/// its [version], [icon] and [legalese]; where the source of what the
|
||||
/// instance runs is ([InstanceSourceCode]), which both licences require
|
||||
/// offering to the people who use the instance; the app's own parts
|
||||
/// ([children], such as its credits); and Flutter's "View licenses", every
|
||||
/// package's notice, which the BSD and MIT packages require in a
|
||||
/// distributed app, the notices the app registers among them.
|
||||
void showAppAbout(
|
||||
BuildContext context, {
|
||||
String? version,
|
||||
Widget? icon,
|
||||
String? legalese,
|
||||
List<Widget> children = const [],
|
||||
}) => showAboutDialog(
|
||||
context: context,
|
||||
applicationName: AppFrameScope.of(context).name(context),
|
||||
applicationVersion: version,
|
||||
applicationIcon: icon,
|
||||
applicationLegalese: legalese,
|
||||
children: [const InstanceSourceCode(), ...children],
|
||||
);
|
||||
|
||||
/// Where the source of what the instance in use runs is, as it names it
|
||||
/// ([AppDependencies.sourceCodeUrl]): nothing while asking, then the address
|
||||
/// to select, or that the instance couldn't be asked.
|
||||
class const InstanceSourceCode({super.key}) extends StatefulWidget {
|
||||
@override
|
||||
State<InstanceSourceCode> createState() => _InstanceSourceCodeState();
|
||||
}
|
||||
|
||||
class _InstanceSourceCodeState() extends State<InstanceSourceCode> {
|
||||
late final Future<String?> _url = _ask();
|
||||
|
||||
/// The address, or null where the instance couldn't be asked.
|
||||
Future<String?> _ask() async {
|
||||
try {
|
||||
return await AppScope.of<AppDependencies<Disposable>>(
|
||||
context,
|
||||
).sourceCodeUrl();
|
||||
} on Exception {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: FutureBuilder<String?>(
|
||||
future: _url,
|
||||
builder: (context, snapshot) =>
|
||||
switch ((snapshot.connectionState, snapshot.data)) {
|
||||
(ConnectionState.done, final url?) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.aboutSourceCode),
|
||||
SelectableText(url),
|
||||
],
|
||||
),
|
||||
(ConnectionState.done, null) => Text(
|
||||
l10n.aboutSourceUnreachable,
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
packages/halkyon_app/lib/src/app/app_locale.dart
Normal file
41
packages/halkyon_app/lib/src/app/app_locale.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The English a Halkyon app speaks wherever it can't speak the language
|
||||
/// asked for: British English, which its template ARB file holds.
|
||||
const _fallback = Locale('en', 'GB');
|
||||
|
||||
/// Which of the [supported] locales the app speaks, for a device that prefers
|
||||
/// the [preferred] ones in this order (or a person who chose one): an app's
|
||||
/// `localeListResolutionCallback`, as `AppFrame.localeListResolutionCallback`
|
||||
/// too.
|
||||
///
|
||||
/// The first preferred language the app speaks wins: in its country's
|
||||
/// variant where there is one, otherwise as the language alone, and English
|
||||
/// of any other country as British English. Unlike Flutter's own resolution,
|
||||
/// a country alone never picks a language (Spanish in the United States
|
||||
/// isn't American English), and where none matches, the app speaks British
|
||||
/// English rather than the first supported locale.
|
||||
Locale resolveAppLocale(List<Locale>? preferred, Iterable<Locale> supported) {
|
||||
for (final locale in preferred ?? const <Locale>[]) {
|
||||
final sameLanguage = supported.where(
|
||||
(candidate) => candidate.languageCode == locale.languageCode,
|
||||
);
|
||||
if (sameLanguage.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
for (final candidate in sameLanguage) {
|
||||
if (candidate.countryCode != null &&
|
||||
candidate.countryCode == locale.countryCode) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
if (locale.languageCode == _fallback.languageCode) {
|
||||
return _fallback;
|
||||
}
|
||||
return sameLanguage.firstWhere(
|
||||
(candidate) => candidate.countryCode == null,
|
||||
orElse: () => sameLanguage.first,
|
||||
);
|
||||
}
|
||||
return _fallback;
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ abstract interface class AppDependencies<P extends Disposable>()
|
|||
/// Whether the person here signed out, so the app asks how to start.
|
||||
SignedOutMark get signedOut;
|
||||
|
||||
/// Where the source of what the instance runs is, as it names it (its
|
||||
/// basics, halkyon_base's): both licences require offering it to the
|
||||
/// people who use the instance, which About does (`showAppAbout`).
|
||||
Future<String> sourceCodeUrl();
|
||||
|
||||
/// Opens the dependencies of [person], once the previous person's have
|
||||
/// closed; `PersonScope` disposes them when the person goes.
|
||||
Future<P> openPerson(SignedIn person);
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ class const InstanceGate({
|
|||
/// Checks an address before the app moves there.
|
||||
required final InstanceChecker checker,
|
||||
|
||||
/// Names the countries instances keep their data in.
|
||||
required final CountryNames countryNames,
|
||||
/// Names the countries instances keep their data in: Unicode CLDR's,
|
||||
/// which come with the package, unless a test gives others.
|
||||
final CountryNames countryNames = const AssetCountryNames(),
|
||||
|
||||
/// Names the subdivisions (states, regions) they keep it in: Unicode
|
||||
/// CLDR's, which come with the package, unless a test gives others.
|
||||
|
|
|
|||
120
packages/halkyon_app/lib/src/auth/widget/account_tiles.dart
Normal file
120
packages/halkyon_app/lib/src/auth/widget/account_tiles.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
import 'package:halkyon_app/src/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_app/src/instance/instance_scope.dart';
|
||||
import 'package:halkyon_app/src/instance/widget/account_switcher_sheet.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The account in use, as an app's settings show it (decided 2026-10-16):
|
||||
/// whose it is, anonymous or signed in, on which instance; switching to the
|
||||
/// account on another instance, where the device keeps accounts on several
|
||||
/// ([InstanceScope]); signing out, which of an anonymous account deletes it
|
||||
/// and so asks first; deleting it, after asking. What only the app can say,
|
||||
/// it gives: what an anonymous account keeps on the device, and what signing
|
||||
/// out of one and deleting the account take with them.
|
||||
class const AccountTiles({
|
||||
/// Under an anonymous account: what it keeps on this device ("Progress is
|
||||
/// saved on this device. Sign in to keep it."); none where null.
|
||||
final String? anonymousNote,
|
||||
|
||||
/// What signing out of an anonymous account takes with it, as the
|
||||
/// question before asks.
|
||||
required final String signOutAnonymousMessage,
|
||||
|
||||
/// What deleting the account takes with it, as the question before asks.
|
||||
required final String deleteMessage,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final identified = switch (context.watch<SessionBloc>().state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
final instances = InstanceScope.maybeOf(context);
|
||||
final instance =
|
||||
instances?.choice.current?.name ??
|
||||
AppScope.of<AppDependencies<Disposable>>(context).serverName;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
identified ? LucideIcons.userCheck : LucideIcons.userRound,
|
||||
),
|
||||
title: Text(
|
||||
identified
|
||||
? l10n.accountSignedInOn(instance)
|
||||
: l10n.accountAnonymousOn(instance),
|
||||
),
|
||||
subtitle: switch (anonymousNote) {
|
||||
final note? when !identified => Text(note),
|
||||
_ => null,
|
||||
},
|
||||
),
|
||||
// The accounts on this device, one per instance.
|
||||
if (instances != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.users),
|
||||
title: Text(l10n.accountSwitch),
|
||||
subtitle: Text(instances.choice.current?.name ?? ''),
|
||||
onTap: () => showAccountSwitcher(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.logOut),
|
||||
title: Text(l10n.accountSignOut),
|
||||
onTap: () => _signOut(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
|
||||
title: Text(
|
||||
l10n.accountDelete,
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
onTap: () => _delete(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Signs out; an anonymous account can't be signed into again, so signing
|
||||
/// out of one deletes it, and asks first. The app then asks how to start.
|
||||
Future<void> _signOut(BuildContext context) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final session = context.read<SessionBloc>();
|
||||
final identified = switch (session.state) {
|
||||
SessionStateActive(:final person) => person.isIdentified,
|
||||
_ => false,
|
||||
};
|
||||
if (!identified &&
|
||||
!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.accountSignOutTitle,
|
||||
message: signOutAnonymousMessage,
|
||||
confirmLabel: l10n.accountSignOut,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
session.add(const SessionEventSignOutRequested());
|
||||
}
|
||||
|
||||
/// Deletes the account with everything in it, after asking.
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final session = context.read<SessionBloc>();
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.accountDeleteTitle,
|
||||
message: deleteMessage,
|
||||
confirmLabel: l10n.accountDelete,
|
||||
)) {
|
||||
session.add(const SessionEventDeleteRequested());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,47 @@
|
|||
/// Countries' names by their code (ISO 3166-1 alpha-2), in a language, as
|
||||
/// the app knows them (Halkyon Learn: its world map's, Unicode CLDR's).
|
||||
/// Tests give them with [MemoryCountryNames].
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Countries' names by their code (ISO 3166-1 alpha-2), in a language.
|
||||
/// [AssetCountryNames] reads Unicode CLDR's, which come with the package;
|
||||
/// tests give them with [MemoryCountryNames].
|
||||
abstract interface class CountryNames() {
|
||||
/// [code]'s name in [language], else in English; null for a code without
|
||||
/// one.
|
||||
Future<String?> nameOf(String code, String language);
|
||||
}
|
||||
|
||||
/// Unicode CLDR's names of countries in English and German, from the
|
||||
/// package's own asset ([countryNamesAsset], written by its
|
||||
/// `tool/country_names.py`), read when first asked for and kept.
|
||||
class const AssetCountryNames() implements CountryNames {
|
||||
/// The names read, by language and then code; kept as values, never as a
|
||||
/// future (a widget test's fake clock never completes one made in its
|
||||
/// real zone).
|
||||
static Map<String, Map<String, String>>? _names;
|
||||
|
||||
@override
|
||||
Future<String?> nameOf(String code, String language) async {
|
||||
final names = _names ??= _read(
|
||||
await rootBundle.loadString(countryNamesAsset),
|
||||
);
|
||||
final key = code.toUpperCase();
|
||||
return names[language]?[key] ?? names['en']?[key];
|
||||
}
|
||||
|
||||
static Map<String, Map<String, String>> _read(String json) => {
|
||||
for (final MapEntry(:key, :value)
|
||||
in (jsonDecode(json) as Map<String, Object?>).entries)
|
||||
if (value is Map<String, Object?>) key: value.cast<String, String>(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Where the names are, among this package's assets: an object of
|
||||
/// languages, each of codes and names; German holds only the names that
|
||||
/// differ from English.
|
||||
const countryNamesAsset =
|
||||
'packages/halkyon_app/assets/countries/countries.json';
|
||||
|
||||
/// The names given, by code and then language, as a test gives them.
|
||||
class MemoryCountryNames(final Map<String, Map<String, String>> _names)
|
||||
implements CountryNames {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ class const InstanceScope({
|
|||
/// Checks an address before the app moves there.
|
||||
required final InstanceChecker checker,
|
||||
|
||||
/// Names the countries instances keep their data in.
|
||||
required final CountryNames countryNames,
|
||||
/// Names the countries instances keep their data in: Unicode CLDR's,
|
||||
/// which come with the package, unless a test gives others.
|
||||
final CountryNames countryNames = const AssetCountryNames(),
|
||||
|
||||
/// Names the subdivisions (states, regions) they keep it in: Unicode
|
||||
/// CLDR's, which come with the package, unless a test gives others.
|
||||
|
|
|
|||
|
|
@ -100,6 +100,20 @@ class TestAppDependencies({
|
|||
@override
|
||||
final MemorySignedOutMark signedOut = MemorySignedOutMark();
|
||||
|
||||
/// Where the instance says its source is.
|
||||
String sourceCode = 'https://code.example.org/halkyon';
|
||||
|
||||
/// Thrown by asking where the source is while set.
|
||||
Exception? sourceCodeError;
|
||||
|
||||
@override
|
||||
Future<String> sourceCodeUrl() async {
|
||||
if (sourceCodeError case final error?) {
|
||||
throw error;
|
||||
}
|
||||
return sourceCode;
|
||||
}
|
||||
|
||||
/// The people whose dependencies were opened, in order.
|
||||
final opened = <TestPerson>[];
|
||||
|
||||
|
|
|
|||
|
|
@ -65,3 +65,6 @@ flutter:
|
|||
# and its licence's notice (registerCldrLicense).
|
||||
- assets/subdivisions/subdivisions.json
|
||||
- assets/subdivisions/LICENSE-CLDR.txt
|
||||
# Unicode CLDR's names of countries in English and German, from the same
|
||||
# release (AssetCountryNames; tool/country_names.py writes them).
|
||||
- assets/countries/countries.json
|
||||
|
|
|
|||
84
packages/halkyon_app/test/app/about_test.dart
Normal file
84
packages/halkyon_app/test/app/about_test.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
void main() {
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() => dependencies = TestAppDependencies());
|
||||
|
||||
/// Opens About from a button, with [version], [legalese] and [children]
|
||||
/// as the app gives them.
|
||||
Future<void> open(
|
||||
WidgetTester tester, {
|
||||
String? version,
|
||||
String? legalese,
|
||||
List<Widget> children = const [],
|
||||
Locale locale = const Locale('en'),
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () => showAppAbout(
|
||||
context,
|
||||
version: version,
|
||||
legalese: legalese,
|
||||
children: children,
|
||||
),
|
||||
child: const Text('About'),
|
||||
),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
locale: locale,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('About'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('names the app and where the source of what the instance runs '
|
||||
'is, which both licences require offering', (tester) async {
|
||||
await open(tester);
|
||||
|
||||
expect(find.text('Halkyon Test'), findsWidgets);
|
||||
expect(find.text('The source of what this instance runs:'), findsOneWidget);
|
||||
expect(find.text('https://code.example.org/halkyon'), findsOneWidget);
|
||||
// Flutter's own button to every package's notice.
|
||||
expect(find.text('View licenses'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets("says so when the instance can't be asked", (tester) async {
|
||||
dependencies.sourceCodeError = Exception('No connection');
|
||||
await open(tester);
|
||||
|
||||
expect(
|
||||
find.text("Couldn't reach this instance to ask where its source is."),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets("shows the app's version, legalese and own parts where it "
|
||||
'gives them', (tester) async {
|
||||
await open(
|
||||
tester,
|
||||
version: '0.18.0',
|
||||
legalese: 'Free software.',
|
||||
children: const [Text('Data in the app')],
|
||||
);
|
||||
|
||||
expect(find.text('0.18.0'), findsOneWidget);
|
||||
expect(find.text('Free software.'), findsOneWidget);
|
||||
expect(find.text('Data in the app'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('speaks German', (tester) async {
|
||||
await open(tester, locale: const Locale('de'));
|
||||
|
||||
expect(
|
||||
find.text('Der Quellcode dessen, was diese Instanz ausführt:'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue