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:
Tizian 「ティツィアーン」 2026-10-19 23:51:52 +02:00
commit 87500b7c6b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 1001 additions and 834 deletions

View file

@ -12,16 +12,8 @@
"homeEmptyHint": "Aufgaben kommen mit einer späteren Version von Halkyon Todo.",
"accountTitle": "Konto",
"accountSignedIn": "Angemeldet auf {instance}",
"accountAnonymous": "Anonym auf {instance}",
"accountSwitch": "Konto wechseln",
"accountSignOut": "Abmelden",
"accountSignOutTitle": "Abmelden?",
"accountSignOutAnonymousMessage": "Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.",
"accountDelete": "Konto löschen",
"accountDeleteTitle": "Dein Konto löschen?",
"accountDeleteMessage": "Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.",
"aboutTitle": "Über",
"aboutSource": "Der Quellcode dessen, was diese Instanz betreibt:"
"aboutTitle": "Über"
}

View file

@ -15,18 +15,8 @@
"homeEmptyHint": "Tasks come with a later version of Halkyon Todo.",
"accountTitle": "Account",
"accountSignedIn": "Signed in on {instance}",
"@accountSignedIn": {"placeholders": {"instance": {"type": "String"}}},
"accountAnonymous": "Anonymous on {instance}",
"@accountAnonymous": {"placeholders": {"instance": {"type": "String"}}},
"accountSwitch": "Switch account",
"accountSignOut": "Sign out",
"accountSignOutTitle": "Sign out?",
"accountSignOutAnonymousMessage": "This account can't be signed into again, so signing out deletes it.",
"accountDelete": "Delete account",
"accountDeleteTitle": "Delete your account?",
"accountDeleteMessage": "Your account is deleted on the server and on all your devices. This can't be undone.",
"aboutTitle": "About",
"aboutSource": "The source of what this instance runs:"
"aboutTitle": "About"
}

View file

@ -148,54 +148,12 @@ abstract class AppLocalizations {
/// **'Account'**
String get accountTitle;
/// No description provided for @accountSignedIn.
///
/// In en, this message translates to:
/// **'Signed in on {instance}'**
String accountSignedIn(String instance);
/// No description provided for @accountAnonymous.
///
/// In en, this message translates to:
/// **'Anonymous on {instance}'**
String accountAnonymous(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 @accountSignOutAnonymousMessage.
///
/// In en, this message translates to:
/// **'This account can\'t be signed into again, so signing out deletes it.'**
String get accountSignOutAnonymousMessage;
/// 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 @accountDeleteMessage.
///
/// In en, this message translates to:
@ -207,12 +165,6 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'About'**
String get aboutTitle;
/// No description provided for @aboutSource.
///
/// In en, this message translates to:
/// **'The source of what this instance runs:'**
String get aboutSource;
}
class _AppLocalizationsDelegate

View file

@ -37,42 +37,14 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get accountTitle => 'Konto';
@override
String accountSignedIn(String instance) {
return 'Angemeldet auf $instance';
}
@override
String accountAnonymous(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 accountSignOutAnonymousMessage =>
'Bei diesem Konto kannst du dich nicht wieder anmelden, deshalb wird es beim Abmelden gelöscht.';
@override
String get accountDelete => 'Konto löschen';
@override
String get accountDeleteTitle => 'Dein Konto löschen?';
@override
String get accountDeleteMessage =>
'Dein Konto wird auf dem Server und auf all deinen Geräten gelöscht. Dies kann nicht rückgängig gemacht werden.';
@override
String get aboutTitle => 'Über';
@override
String get aboutSource => 'Der Quellcode dessen, was diese Instanz betreibt:';
}

View file

@ -37,44 +37,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get accountTitle => 'Account';
@override
String accountSignedIn(String instance) {
return 'Signed in on $instance';
}
@override
String accountAnonymous(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 accountSignOutAnonymousMessage =>
'This account can\'t be signed into again, so signing out deletes it.';
@override
String get accountDelete => 'Delete account';
@override
String get accountDeleteTitle => 'Delete your account?';
@override
String get accountDeleteMessage =>
'Your account is deleted on the server and on all your devices. This can\'t be undone.';
@override
String get aboutTitle => 'About';
@override
String get aboutSource => 'The source of what this instance runs:';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -32,9 +32,6 @@ Future<void> main() async {
// Only an instance of Halkyon Todo: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'todo'),
// No country names yet: where an instance keeps people's data shows
// its flag and the country's code.
countryNames: MemoryCountryNames(const {}),
open: (instance) => TodoDependencies.create(instance, device),
),
);

View file

@ -1,17 +1,13 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_frame.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, as Learn's Settings shows it: whose it is, on which instance,
/// switching to the account on another, signing out and deleting it; and
/// About.
/// The account: halkyon_app's section (whose it is, on which instance,
/// switching to the account on another, signing out and deleting it) in
/// Todo's words where only Todo can say it; and About.
Future<void> showAccountSheet(BuildContext context) => showAdaptiveSheet(
context,
builder: (context) => const _AccountSheet(),
@ -21,107 +17,29 @@ class const _AccountSheet() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final instance = TodoDependencies.of(context).serverName;
final identified = switch (context.watch<SessionBloc>().state) {
SessionStateActive(:final person) => person.isIdentified,
_ => false,
};
return SheetScaffold(
title: l10n.accountTitle,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: Icon(
identified ? LucideIcons.userCheck : LucideIcons.userRound,
),
title: Text(
identified
? l10n.accountSignedIn(instance)
: l10n.accountAnonymous(instance),
),
),
// The accounts on this device, one per instance.
ListTile(
leading: const Icon(LucideIcons.users),
title: Text(l10n.accountSwitch),
onTap: () => showAccountSwitcher(context),
),
ListTile(
leading: const Icon(LucideIcons.logOut),
title: Text(l10n.accountSignOut),
onTap: () => _signOut(context, identified: identified),
),
ListTile(
leading: Icon(LucideIcons.trash2, color: theme.colorScheme.error),
title: Text(
l10n.accountDelete,
style: TextStyle(color: theme.colorScheme.error),
),
onTap: () => _delete(context),
AccountTiles(
signOutAnonymousMessage: l10n.accountSignOutAnonymousMessage,
deleteMessage: l10n.accountDeleteMessage,
),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle),
onTap: () => unawaited(_showAbout(context)),
// halkyon_app's About: the app's name and icon, the source of
// what the instance runs, which both licences ask to offer, and
// the licence list.
onTap: () => showAppAbout(
context,
icon: const Image(image: todoIcon, width: 48, height: 48),
),
),
],
),
);
}
}
/// 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, {required bool identified}) async {
final l10n = AppLocalizations.of(context);
final session = context.read<SessionBloc>();
if (!identified &&
!await confirmDestructiveAction(
context,
title: l10n.accountSignOutTitle,
message: l10n.accountSignOutAnonymousMessage,
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 = AppLocalizations.of(context);
final session = context.read<SessionBloc>();
if (await confirmDestructiveAction(
context,
title: l10n.accountDeleteTitle,
message: l10n.accountDeleteMessage,
confirmLabel: l10n.accountDelete,
)) {
session.add(const SessionEventDeleteRequested());
}
}
/// The app's About: its name and icon, the source of what the instance runs
/// (as its basics name it, which both licences ask to offer), and the licence
/// list.
Future<void> _showAbout(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final source = await TodoDependencies.of(context).sourceCode();
if (!context.mounted) {
return;
}
showAboutDialog(
context: context,
applicationName: l10n.appTitle,
applicationIcon: const Image(image: todoIcon, width: 48, height: 48),
children: [
if (source != null) ...[
Text(l10n.aboutSource),
SelectableText('$source'),
],
],
);
}

View file

@ -23,8 +23,9 @@ class const TodoApp({
/// Todo's.
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,
/// halkyon_app's, unless a test gives others.
final CountryNames countryNames = const AssetCountryNames(),
/// Todo's dependencies on an instance.
required final Future<TodoDependencies> Function(Instance instance) open,
@ -60,7 +61,7 @@ class const TodoPersonApp({
darkTheme: appTheme(Brightness.dark),
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveTodoLocale,
localeListResolutionCallback: resolveAppLocale,
builder: (context, child) => PersonScope(person: person, child: child!),
home: const HomeScreen(),
);

View file

@ -36,8 +36,9 @@ class TodoDependencies({
@override required final SignedOutMark signedOut,
/// Where the source of what the instance runs is, as its basics name it
/// (halkyon_base): both licences ask to offer it, which About does.
required final Future<Uri?> Function() sourceCode,
/// (halkyon_base): both licences ask to offer it, which About does
/// ([sourceCodeUrl]).
required final Future<String> Function() _sourceCodeUrl,
/// Opens a signed-in person's dependencies ([openPerson]).
required final OpenTodoPerson _openPerson,
@ -50,6 +51,9 @@ class TodoDependencies({
@override
Future<TodoPerson> openPerson(SignedIn person) => _openPerson(person);
@override
Future<String> sourceCodeUrl() => _sourceCodeUrl();
/// Builds the production graph on [instance], around one Serverpod client
/// of its own, with what [device] shares across instances. Its session and
/// its people's folders are the instance's own (`InstanceStorage`); the
@ -83,9 +87,8 @@ class TodoDependencies({
serverName: instance.name,
auth: AuthRepository(authGateway, FileSignInAddresses(people)),
signedOut: FileSignedOutMark(people),
sourceCode: () async => Uri.tryParse(
(await client.modules.base.instance.basics()).sourceCodeUrl,
),
sourceCodeUrl: () async =>
(await client.modules.base.instance.basics()).sourceCodeUrl,
openPerson: (person) async {
// One person's folder on the device at a time, as Learn keeps it.
await folders.openOnly(person.authUserId);

View file

@ -21,7 +21,7 @@ const todoFrame = AppFrame(
theme: appTheme,
localizationsDelegates: todoLocalizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: resolveTodoLocale,
localeListResolutionCallback: resolveAppLocale,
launchScreen: TodoLaunchScreen(),
);

View file

@ -13,40 +13,3 @@ const todoLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
HalkyonUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
];
/// The English the app speaks wherever it can't speak the language asked for:
/// British English, which `app_en.arb` holds.
const _fallback = Locale('en', 'GB');
/// Which of the [supported] locales the app speaks, for a device preferring
/// the [preferred] ones in this order, as Learn picks (`resolveAppLocale`):
/// the first language it speaks wins, in its country's variant where there is
/// one, English of any other country as British English, and a country alone
/// never picks a language.
Locale resolveTodoLocale(
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;
}

View file

@ -46,8 +46,8 @@ void main() {
serverName: instance.name,
auth: AuthRepository(gateway, MemorySignInAddresses()),
signedOut: signedOut,
sourceCode: () async =>
Uri.parse('https://code.halkyon.app/project-halkyon/halkyon'),
sourceCodeUrl: () async =>
'https://code.halkyon.app/project-halkyon/halkyon',
openPerson: (person) async {
opened.add(person);
return TodoPerson(person);