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
13 changed files with 1001 additions and 834 deletions

View file

@ -4,6 +4,6 @@ What holds for every app and package of this repository is in the [root AGENTS.m
- **A shell on the shared packages** (decided 2026-10-19): signing in, the instances, the account and the look work; nothing of a person's is kept or synced yet, which waits for Todo's first model (the developer decides what it keeps and syncs). Packages: `halkyon_todo_server`, `halkyon_todo_client` (generated, never edited), `halkyon_todo_flutter`; app ID `app.halkyon.todo` on every platform; no web build. Licences as Learn's (server SSPL, client and app AGPL; `LICENSE.md`).
- **The server** includes `halkyon_base` (ADR 0014) and configures it in `server.dart` (`todoBase()`, `lib/src/todo_base.dart`): its basics say it serves `todo` (never change it once apps use it), sign-in mails name Halkyon Todo, the source is this repository, `minimumAppBuild` 1, no data of its own to delete with a person yet (add a `deletePersonData` hook with Todo's first table). Authentication through the module's builders: anonymous, GitHub (with the instance's own OAuth app) and email codes, server-side sessions. **Accounts** (ADR 0016): Todo holds its own register when it runs alone, and is a member of the instance's register (Learn's server on learn.halkyon.eu) where the operator sets `ACCOUNT_REGISTER_URL` (`todoBase(accounts: AccountsMode.fromEnvironment())`) with the instance's shared `INSTANCE_SECRET` (`instanceSecret`); then sign-in, handles and profiles are the register's, the email settings are the register's alone (GitHub's are Todo's own OAuth app's), and an identified person's deletion goes to the register first. `addInstanceRoutes` serves the calls between the services on its web server, and the server doesn't start without the secret where it needs it. `schedulePruning` (`lib/src/pruning.dart`) keeps the module's pruning scheduled, one entry each however often the server starts: anonymous people daily, sign-up attempts and codes hourly, as the register the deletions its members haven't confirmed hourly, as a member the people the register no longer knows daily (each a no-op in the other mode). Session logs are never persisted, in any run mode. Migrations (2026-10-19): the initial one (Serverpod's tables, its auth modules' and the module's `email_identity`, `email_code_request`), `profiles` (`public_profile`) and `deletion-notices` (`account_deletion_notice`), nothing of Todo's own. Tests (`test/integration/todo_server_test.dart`) call the module's endpoints through the server's own dispatch (`halkyon_base.<endpoint>`), since the generated test tools expose the project's endpoints alone.
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own three sentences) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first, and About with the source of what the instance runs and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data show by their codes until halkyon_app names countries itself. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The app** is halkyon_app's `InstanceGate` and `SessionGate` with Todo's frame (`todoFrame`: "Todo" under "Halkyon", its icon, `todo.example.org`, its own three sentences) and `ServerpodInstanceChecker(app: 'todo')`; `TodoDependencies` (`AppDependencies<TodoPerson>`) on one instance, `TodoPerson` (empty) per person; a person's app with an empty home and the account sheet: halkyon_app's `AccountTiles` (whose account on which instance, Switch account, Sign out, which asks first for an anonymous account as it deletes it, Delete account, which asks first) in Todo's words, and halkyon_app's About (`showAppAbout`: the source of what the instance runs, from `TodoDependencies.sourceCodeUrl`, and the licence list). Texts through gen-l10n in British English (the template), German (informal du), an empty `en_US` and `en_GB` (`test/l10n/arb_files_test.dart` keeps them in step). The look follows the system's theme mode and language until Todo has settings. Countries in where an instance keeps its data are named by halkyon_app's CLDR names (`AssetCountryNames`), its language picked by halkyon_app's `resolveAppLocale`. Widget tests start Todo's whole tree with halkyon_app's fakes (`test/app/todo_app_test.dart`).
- **The icon** is Lucide's `list-checks` (lucide-static 1.50.0) in the brand's blues on white: `apps/todo/tool/app_icons.py` (run from `apps/todo`) fetches it, draws its strokes with Pillow (no SVG renderer needed; other path commands fail loudly), writes `assets/icon/halkyon_todo_icon.png` and Lucide's ISC licence beside it, then every platform's launcher icon through flutter_launcher_icons as Learn's tool does. `registerLucideLicense` puts the notice in the licence list.
- **`serverpod start`** runs in `apps/todo/halkyon_todo_server` (it launches the app from `lib/driver.dart`); `.mcp.json`'s `--server-dir` names it when a session works on Todo. Its tests need throwaway passwords in its git-ignored `config/passwords.yaml` (`database`, `serviceSecret`, `serverSideSessionKeyHashPepper`, `emailSignInPepper`) or from the environment, as CI gives them. Learn's and Todo's development servers use the same ports: run one at a time.

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