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
33 changed files with 616 additions and 49 deletions

View file

@ -24,6 +24,9 @@ tests in its folder (`dart test`, or `flutter test` for `halkyon_ui`, its
example and `halkyon_app`). `halkyon_base_server`'s tests start Serverpod's embedded PostgreSQL by
themselves and need a git-ignored `config/passwords.yaml` (see its README).
`tool/subdivision_names.py` writes `halkyon_app`'s names of subdivisions from a
release of Unicode CLDR (`python3 -I tool/subdivision_names.py release-48-2`).
Licences: the server package `halkyon_base_server` is under the Server Side
Public License 1.0, as the Halkyon servers are; every other package under the
GNU Affero General Public License 3.0 or later.

View file

@ -21,8 +21,14 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
another, as tiles, `InstanceTile`), `InstanceStep` (an address, checked),
`showAccountSwitcher` and `AddAccountScreen`, and where an instance keeps
people's data, as a flag and words (`InstanceLocationBuilder`,
`InstanceFlag`, `instanceLocationWords`, countries named by the app's
`CountryNames`).
`InstanceFlag`, `instanceLocationWords`): `EU`, a country or a subdivision
(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
workspace's `tool/subdivision_names.py` writes them).
- **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
@ -99,11 +105,14 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
A person's widgets find their dependencies with
`PersonScope.of<PersonDependencies>(context)` and the app's with
`AppScope.of<Dependencies>(context)`.
7. **Licences**: call `registerFlagIconsLicense()` once at startup, beside
halkyon_ui's `registerFontLicenses()`.
7. **Licences**: call `registerHalkyonAppLicenses()` once at startup (the
flags' and Unicode CLDR's notices), beside halkyon_ui's
`registerFontLicenses()`.
## Licence
GNU Affero General Public License 3.0 or later (`LICENSE`). The flags are
lipis' flag-icons, drawn by the `flag` package, under the MIT licence
(`assets/flags/LICENSE-flag-icons.txt`).
(`assets/flags/LICENSE-flag-icons.txt`). The subdivisions' names are Unicode
CLDR's (release 48.2), under the Unicode License v3
(`assets/subdivisions/LICENSE-CLDR.txt`, `assets/subdivisions/ATTRIBUTION.md`).

View file

@ -0,0 +1,12 @@
# Subdivisions' names
`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
`tool/subdivision_names.py`: English whole, German where it differs, variants left
out.
CLDR is under the Unicode License v3, whose notice is in `LICENSE-CLDR.txt`,
verbatim from that release, and in the app's licence list
(`registerHalkyonAppLicenses`).

View file

@ -0,0 +1,41 @@
UNICODE LICENSE V3
COPYRIGHT AND PERMISSION NOTICE
Copyright © 2004-2026 Unicode, Inc.
NOTICE TO USER: Carefully read the following legal agreement. BY
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
Permission is hereby granted, free of charge, to any person obtaining a
copy of data files and any associated documentation (the "Data Files") or
software and any associated documentation (the "Software") to deal in the
Data Files or Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, and/or sell
copies of the Data Files or Software, and to permit persons to whom the
Data Files or Software are furnished to do so, provided that either (a)
this copyright and permission notice appear with all copies of the Data
Files or Software, or (b) this copyright and permission notice appear in
associated Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall
not be used in advertising or otherwise to promote the sale, use or other
dealings in these Data Files or Software without prior written
authorization of the copyright holder.
SPDX-License-Identifier: Unicode-3.0

File diff suppressed because one or more lines are too long

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

View file

@ -60,3 +60,8 @@ flutter:
assets:
# flag-icons' MIT notice, for the licence list (registerFlagIconsLicense).
- assets/flags/LICENSE-flag-icons.txt
# Unicode CLDR's names of subdivisions (states, regions) in English and
# German (AssetSubdivisionNames; tool/subdivision_names.py writes them),
# and its licence's notice (registerCldrLicense).
- assets/subdivisions/subdivisions.json
- assets/subdivisions/LICENSE-CLDR.txt

Some files were not shown because too many files have changed in this diff Show more