halkyon_app: data locations as precise as a country or a state

An operator's DATA_LOCATION and BACKUP_LOCATION may name a subdivision
(ISO 3166-2, US-CA, DE-BY) besides the EU or a country. The app adds
"(EU)" to a member country and the country to a subdivision ("Germany
(EU)", "California, United States", "Bavaria, Germany (EU)"), in
English and German, and shows the most specific flag there is: a
subdivision's own where the flag package has one, else its country's.
Subdivision names come from Unicode CLDR 48.2 (tool/subdivision_names.py),
bundled with their licence, which the app registers.

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 18:47:25 +02:00
commit 96f2d14618
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 616 additions and 49 deletions

View file

@ -43,7 +43,9 @@ 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/cldr_license.dart';
export 'src/instance/country_names.dart';
export 'src/instance/european_union.dart';
export 'src/instance/flag_icons_license.dart';
export 'src/instance/instance.dart';
export 'src/instance/instance_checker.dart';
@ -52,6 +54,7 @@ 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/subdivision_names.dart';
export 'src/instance/widget/account_switcher_sheet.dart';
export 'src/instance/widget/first_start_screen.dart';
export 'src/instance/widget/instance_location.dart';

View file

@ -17,6 +17,8 @@
"instanceBackupsIn": "Sicherungen: {place}",
"instanceNoBackups": "Keine Sicherungen",
"instanceEuropeanUnion": "Europäische Union",
"instanceCountryInEu": "{country} (EU)",
"instanceSubdivisionOf": "{subdivision}, {country}",
"accountsTitle": "Konten",
"accountAdd": "Konto hinzufügen",
"accountAddSubtitle": "Wähle die Instanz für das Konto, das du hinzufügst.",

View file

@ -70,6 +70,27 @@
},
"instanceNoBackups": "No backups",
"instanceEuropeanUnion": "European Union",
"instanceCountryInEu": "{country} (EU)",
"@instanceCountryInEu": {
"description": "A country of the European Union, where an instance keeps data: Germany (EU).",
"placeholders": {
"country": {
"type": "String"
}
}
},
"instanceSubdivisionOf": "{subdivision}, {country}",
"@instanceSubdivisionOf": {
"description": "A state or region with its country, where an instance keeps data: California, United States.",
"placeholders": {
"subdivision": {
"type": "String"
},
"country": {
"type": "String"
}
}
},
"accountsTitle": "Accounts",
"accountAdd": "Add account",
"accountAddSubtitle": "Choose the instance for the account to add.",

View file

@ -205,6 +205,18 @@ abstract class HalkyonAppLocalizations {
/// **'European Union'**
String get instanceEuropeanUnion;
/// A country of the European Union, where an instance keeps data: Germany (EU).
///
/// In en, this message translates to:
/// **'{country} (EU)'**
String instanceCountryInEu(String country);
/// A state or region with its country, where an instance keeps data: California, United States.
///
/// In en, this message translates to:
/// **'{subdivision}, {country}'**
String instanceSubdivisionOf(String subdivision, String country);
/// No description provided for @accountsTitle.
///
/// In en, this message translates to:

View file

@ -74,6 +74,16 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get instanceEuropeanUnion => 'Europäische Union';
@override
String instanceCountryInEu(String country) {
return '$country (EU)';
}
@override
String instanceSubdivisionOf(String subdivision, String country) {
return '$subdivision, $country';
}
@override
String get accountsTitle => 'Konten';

View file

@ -74,6 +74,16 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get instanceEuropeanUnion => 'European Union';
@override
String instanceCountryInEu(String country) {
return '$country (EU)';
}
@override
String instanceSubdivisionOf(String subdivision, String country) {
return '$subdivision, $country';
}
@override
String get accountsTitle => 'Accounts';

View file

@ -9,6 +9,7 @@ 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/subdivision_names.dart';
import 'package:halkyon_app/src/instance/widget/first_start_screen.dart';
import 'package:material_ui/material_ui.dart';
@ -32,6 +33,10 @@ class const InstanceGate({
/// Names the countries instances keep their data in.
required final CountryNames countryNames,
/// Names the subdivisions (states, regions) they keep it in: Unicode
/// CLDR's, which come with the package, unless a test gives others.
final SubdivisionNames subdivisionNames = const AssetSubdivisionNames(),
/// The dependencies of the app on an instance.
required final Future<AppDependencies<Disposable>> Function(
Instance instance,
@ -136,6 +141,7 @@ class _InstanceGateState() extends State<InstanceGate> {
notifier: widget.choice,
checker: widget.checker,
countryNames: widget.countryNames,
subdivisionNames: widget.subdivisionNames,
child: switch (current) {
null => PlainApp(look: _look, home: const FirstStartScreen()),
final instance when dependencies != null && _opened == instance =>

View file

@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:halkyon_app/src/instance/flag_icons_license.dart';
/// Adds Unicode CLDR's notice to the app's licence list, beside the
/// packages' own: the subdivisions' names come from it
/// (`AssetSubdivisionNames`), and its licence asks that its notice go with
/// them.
void registerCldrLicense() => LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks(
const ['Unicode CLDR'],
await rootBundle.loadString(cldrLicenseAsset),
);
});
/// Where the notice is, among this package's assets: verbatim from the
/// release the names come from.
const cldrLicenseAsset =
'packages/halkyon_app/assets/subdivisions/LICENSE-CLDR.txt';
/// Adds every notice the package's own assets ask for to the app's licence
/// list: the flags' ([registerFlagIconsLicense]) and Unicode CLDR's
/// ([registerCldrLicense]). An app calls it once at startup.
void registerHalkyonAppLicenses() {
registerFlagIconsLicense();
registerCldrLicense();
}

View file

@ -0,0 +1,9 @@
/// The European Union's member states, by the codes ISO 3166-1 gives them
/// (Greece as `GR`; the Union's own lists say `EL`), as of 2026: a place in
/// one of them is said to be in the EU too ("Germany (EU)"), as its law
/// applies there beside the country's. Change it when the Union does.
const europeanUnionMembers = {
'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', //
'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', //
'PT', 'RO', 'SE', 'SI', 'SK',
};

View file

@ -1,10 +1,10 @@
import 'package:equatable/equatable.dart';
/// Where an instance keeps people's data and its backups, as its operator
/// says (`instance.basics()`): each a country's code (ISO 3166-1 alpha-2,
/// `CH`) or `EU`, the backups also [noBackups]; null where the instance
/// says nothing. Where the disks are, not which law
/// governs.
/// says (`instance.basics()`): each `EU`, a country's code (ISO 3166-1
/// alpha-2, `DE`) or a subdivision's (ISO 3166-2, `US-CA`), in capitals, the
/// backups also [noBackups]; null where the instance says nothing. Where
/// the disks are, not which law governs.
final class const InstanceLocations({
/// Where people's data is.
final String? data,
@ -18,6 +18,14 @@ final class const InstanceLocations({
/// The European Union's code, ISO 3166's reserved one.
static const europeanUnion = 'EU';
/// The country [place] is in: a subdivision's first two letters (`US` of
/// `US-CA`), a country itself.
static String countryOf(String place) => place.substring(0, 2);
/// Whether [place] is a subdivision's code (ISO 3166-2, `US-CA`) rather
/// than a country's or the European Union's.
static bool isSubdivision(String place) => place.contains('-');
@override
List<Object?> get props => [data, backups];
}

View file

@ -1,6 +1,7 @@
import 'package:halkyon_app/src/instance/country_names.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/subdivision_names.dart';
import 'package:material_ui/material_ui.dart';
/// Hands the instances the device keeps accounts on ([InstanceChoice]) to
@ -13,6 +14,10 @@ class const InstanceScope({
/// Names the countries instances keep their data in.
required final CountryNames countryNames,
/// Names the subdivisions (states, regions) they keep it in: Unicode
/// CLDR's, which come with the package, unless a test gives others.
final SubdivisionNames subdivisionNames = const AssetSubdivisionNames(),
required super.child,
super.key,
}) extends InheritedNotifier<InstanceChoice> {

View file

@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:flutter/services.dart';
/// Subdivisions' names (states, regions, provinces) by their code (ISO
/// 3166-2, `US-CA`), in a language. [AssetSubdivisionNames] reads Unicode
/// CLDR's, which come with the package; tests give them with
/// [MemorySubdivisionNames].
abstract interface class SubdivisionNames() {
/// [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 subdivisions in English and German, from the
/// package's own asset ([subdivisionNamesAsset], written by the workspace's
/// `tool/subdivision_names.py`), read when first asked for and kept.
class const AssetSubdivisionNames() implements SubdivisionNames {
/// 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(subdivisionNamesAsset),
);
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 subdivisionNamesAsset =
'packages/halkyon_app/assets/subdivisions/subdivisions.json';
/// The names given, by code and then language, as a test gives them.
class const MemorySubdivisionNames(
final Map<String, Map<String, String>> _names,
) implements SubdivisionNames {
@override
Future<String?> nameOf(String code, String language) async {
final of = _names[code.toUpperCase()];
return of?[language] ?? of?['en'];
}
}

View file

@ -9,10 +9,10 @@ import 'package:halkyon_app/src/instance/widget/instance_location_words.dart';
import 'package:material_ui/material_ui.dart';
/// Where [instance] says it keeps people's data, asked of it as this is
/// first built: [builder] gets the code of the flag to show (where the data
/// is) and the words ([instanceLocationWords], the countries named in the
/// app's language) once it answers, nulls until then and where it says
/// nothing.
/// first built: [builder] gets the place whose flag to show (where the data
/// is, for [InstanceFlag]) and the words ([instanceLocationWords], the
/// countries and subdivisions named in the app's language) once it
/// answers, nulls until then and where it says nothing.
class const InstanceLocationBuilder({
required final Instance instance,
required final Widget Function(
@ -51,9 +51,20 @@ class _InstanceLocationBuilderState() extends State<InstanceLocationBuilder> {
return;
}
final names = <String, String>{};
for (final code in {locations.data, locations.backups}.nonNulls) {
if (await scope.countryNames.nameOf(code, language) case final name?) {
names[code] = name;
for (final place in {locations.data, locations.backups}.nonNulls) {
if (place == InstanceLocations.noBackups ||
place == InstanceLocations.europeanUnion) {
continue;
}
final country = InstanceLocations.countryOf(place);
if (await scope.countryNames.nameOf(country, language) case final name?) {
names[country] = name;
}
if (InstanceLocations.isSubdivision(place)) {
if (await scope.subdivisionNames.nameOf(place, language)
case final name?) {
names[place] = name;
}
}
}
if (mounted && language == _language) {
@ -81,11 +92,12 @@ class _InstanceLocationBuilderState() extends State<InstanceLocationBuilder> {
}
}
/// The flag of [code] (a country's code, or `EU`) in a white ring, [size]
/// across; nothing for a code without a flag. Screen readers hear the
/// place in the words beside it instead.
/// The flag of [place] ([flagCodeOf]: `EU`, a country, or a subdivision,
/// by its own flag or its country's) in a white ring, [size] across;
/// nothing for a place without a flag. Screen readers hear the place in
/// the words beside it instead.
class const InstanceFlag(
final String code, {
final String place, {
final double size = 26,
super.key,
}) extends StatelessWidget {
@ -94,7 +106,8 @@ class const InstanceFlag(
@override
Widget build(BuildContext context) {
if (!Flag.flagsCode.contains(code.toLowerCase())) {
final code = flagCodeOf(place);
if (code == null) {
return const SizedBox.shrink();
}
const inner = 2 * ring;
@ -126,3 +139,17 @@ class const InstanceFlag(
);
}
}
/// The flag package's code of the most specific flag [place] has (`EU`, a
/// country's code or a subdivision's, ISO 3166-2): a subdivision's own
/// where there is one (the package draws a few: Scotland's `gb_sct`,
/// Catalonia's `es_ct`), else its country's, the European Union's for
/// `EU`; null where there is none.
String? flagCodeOf(String place) {
final own = place.toLowerCase().replaceAll('-', '_');
if (Flag.flagsCode.contains(own)) {
return own;
}
final country = InstanceLocations.countryOf(place).toLowerCase();
return Flag.flagsCode.contains(country) ? country : null;
}

View file

@ -1,20 +1,35 @@
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/instance/european_union.dart';
import 'package:halkyon_app/src/instance/instance_locations.dart';
/// Where [locations] says an instance keeps people's data and its backups,
/// in words, the countries by their [names] (by code; one without goes by
/// its code): "Data and backups: European Union", else a line each, "Data:
/// European Union" over "Backups: Switzerland" or "No backups"; null where
/// it says nothing. Labels rather than sentences, since "in" would need the
/// article some countries take (in the Netherlands, in der Schweiz).
/// in words, the countries and subdivisions by their [names] (by code; one
/// without goes by its code): "Data and backups: European Union", else a
/// line each, "Data: Bavaria, Germany (EU)" over "Backups: Switzerland" or
/// "No backups"; null where it says nothing. A subdivision is named with
/// its country, and a member of the European Union says so, as the Union's
/// law applies there too (decided 2026-10-19). Labels rather than sentences,
/// since "in" would need the article some countries take (in the
/// Netherlands, in der Schweiz).
String? instanceLocationWords(
HalkyonAppLocalizations l10n,
InstanceLocations locations,
Map<String, String> names,
) {
String place(String code) => code == InstanceLocations.europeanUnion
? l10n.instanceEuropeanUnion
: names[code] ?? code;
String place(String code) {
if (code == InstanceLocations.europeanUnion) {
return l10n.instanceEuropeanUnion;
}
final country = InstanceLocations.countryOf(code);
final countryName = names[country] ?? country;
final inCountry = europeanUnionMembers.contains(country)
? l10n.instanceCountryInEu(countryName)
: countryName;
return code == country
? inCountry
: l10n.instanceSubdivisionOf(names[code] ?? code, inCountry);
}
final InstanceLocations(:data, :backups) = locations;
if (data != null && data == backups) {
return l10n.instanceDataAndBackupsIn(place(data));