feat(instance): an instance says where it keeps people's data
Its operator sets DATA_LOCATION and BACKUP_LOCATION in deploy/.env (a
country's code or EU, the backups also none), and instance.info() hands
them on. The first start's tile and Switch account show the flag of
where the data is and the words, as labels ("Data: European Union",
"Backups: Switzerland"), the countries by the world map's CLDR names:
where the disks are, never which law governs. The flags come from the
flag package, which draws flag-icons; its MIT notice joins the licence
list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9b0f28d7c1
commit
642c1c7e9f
36 changed files with 867 additions and 63 deletions
|
|
@ -161,7 +161,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
|
||||
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
|
||||
- **Older apps** can't be made to update once published. Before the first sync of each launch the app asks whether the server still works with its build (`minimumAppBuild` in `instance.info()`, from `lib/src/instance/app_compatibility.dart` in the server; the build is the number after `+` in the app's version), and the sync engine refuses an app whose synced tables differ from the server's. Both end in `AppServerMismatchException` (`core/sync/`), and the app says so: a banner on Home and the Sync card in Settings. Raise `minimumAppBuild` in the commit that breaks older apps (an endpoint they call changes or goes), to the build of the first app that works again; a change to the synced tables needs no raise. Every release raises the build number.
|
||||
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`, under the wordmark on a cobalt wash: two tiles as Proton's account page shows its services, decided 2026-10-18, the build's own in one tap, and another instance, which unfolds the address to type, checked with `instance.info()`; `InstanceTile`: a ring from the primary colour to the tertiary, a glow and a tagline come in under the pointer, on the keyboard's focus, while pressed or open, and without a mouse the taglines always show), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **Signed out, the app asks how to start** (decided 2026-10-16, `StartScreen`): on the instance in use, with GitHub, by email or without an account, or on another instance; it makes an anonymous account by itself only at the very first start. A session the server ended leads there too. That the device is signed out is kept in the people's folder (`SignedOutMark`, `signed_out`), so a restart asks again. Settings names the kind of account and its instance ("Anonymous on learn.halkyon.eu").
|
||||
- **Instances** (decided 2026-10-10, as Moshidon does): the device keeps an account on each instance it was used with, one in use at a time (`core/instance/`). `InstanceChoice` holds them (`instances.json` in the app's support folder); a new install chooses at first start (`FirstStartScreen`, under the wordmark on a cobalt wash: two tiles as Proton's account page shows its services, decided 2026-10-18, the build's own in one tap, with where it keeps people's data (below), and another instance, which unfolds the address to type, checked with `instance.info()`; `InstanceTile`: a ring from the primary colour to the tertiary, a glow and a tagline come in under the pointer, on the keyboard's focus, while pressed or open, and without a mouse the taglines always show), and one from before there was a choice goes on with the build's (`SERVER_URL`), as it has a `people/` folder. `InstanceGate` builds `Dependencies.create(instance, device)` for the one in use and builds it anew on a switch, the previous closed first; `DeviceParts` (version, sounds, Pomodoro platform, device settings) outlive switches. Each instance's people's folders and session are its own (`InstanceStorage`): the build's own instance keeps `people/` and Serverpod's own session key, so existing installs keep everything; others `instances/<name>/people/` and a key of their own. Settings › Switch account lists them (`showAccountSwitcher`): another one switches, Add account chooses another instance, and removing one not in use deletes its folder and stored session (the session stays valid on the instance until it expires). Signing out still ends only the account in use. **Signed out, the app asks how to start** (decided 2026-10-16, `StartScreen`): on the instance in use, with GitHub, by email or without an account, or on another instance; it makes an anonymous account by itself only at the very first start. A session the server ended leads there too. That the device is signed out is kept in the people's folder (`SignedOutMark`, `signed_out`), so a restart asks again. Settings names the kind of account and its instance ("Anonymous on learn.halkyon.eu"). **Where an instance keeps people's data** (decided 2026-10-18): its operator says so in `deploy/.env` (`DATA_LOCATION`, `BACKUP_LOCATION`: a country's code or `EU`, the backups also `none`; where the disks are, never which law governs, as a court reaches the operator wherever the server stands), `instance.info()` hands it on, and the first start's tile and Switch account show it once the instance answers (`InstanceLocationBuilder`): the flag of where the data is (`InstanceFlag`, from the `flag` package, which draws lipis' flag-icons, whose MIT notice the app registers itself) and the words, as labels rather than sentences ("Data: European Union", "Backups: Switzerland"), since "in" would need the article some countries take; countries are named by the world map's CLDR names (`CountryNames`).
|
||||
- **Asking an AI assistant or searching the web** (decided 2026-10-10; `feature/assistant/`): a device setting, off unless the person turns it on (Settings › Assistant), shows a field at the foot of Home (`AssistantBar`) and a button that unfolds into one (`AssistantButton`): at the lower left of the Library, across from Create studyset, at the left above a studyset's bottom bar (floating alone at the lower left from expanded windows on), and at the lower left while studying, above the buttons that answer the card (decided 2026-10-16, as Vaia places it). A switch at the field's start sends to the AI assistant chosen in Settings, named in the field, or to a web search and is remembered (`assistantTarget`). Above the field, chips offer the parts of what it is about (`AssistantTopic.parts`: a card's front, back, each hint, its explanation, the studyset's name and description), none chosen, so only what the person picks goes along, each cut at 600 characters; a web search takes the question and the chosen text, never the studyset's name or description. Nothing is answered here: `AssistantHandOff` opens the chosen assistant's new conversation with the question and a compact line per chosen part written in, for the person to send (`AiAssistant`, decided 2026-10-14: Proton's Lumo by default, by its guest chat `https://lumo.proton.me/guest#prefill=…`, the question in the fragment, which reaches no server, as Lumo's page reads it, undocumented; its address for one's own account loses the question when nobody is signed in; then ChatGPT, Claude, Mistral, Perplexity, which answers at once, Duck.ai, Grok, which asks first, Kagi Assistant, HuggingChat, or the person's own address with `%s`; only assistants whose address fills a question in, so not Gemini, which has none, nor Copilot, whose stopped working in November 2025; each cut at what it takes, 8,000 to 12,000 characters), and `WebSearch` opens the chosen engine's results (`SearchEngine`: Brave Search by default on desktops, Ecosia, DuckDuckGo, Startpage, Qwant, Google, Bing, or the person's own address with `%s`), or on Android hands the question to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Halkyon apps use no LLM API keys: their pricing is opaque and open to abuse, and the person decides each time what an LLM sees. A Claude plan can't drive a chat inside another app (Anthropic allows no third party to sign in with it), so Claude reads studysets through the instance's connector instead (ADR 0006): `halkyon_learn_server/lib/src/assistant/` serves MCP (`/mcp`, Streamable HTTP, JSON answers, no sessions; five read-only tools in `connector_tools.dart`) and its OAuth (`/.well-known/oauth-*`, `/oauth/register`, `/oauth/authorize`, `/oauth/token`) on Serverpod's web server, which production runs on 8082 for these paths alone (Caddy forwards them; `connectorOf` takes the public address from `webServer`). People connect with a code from Settings › Assistant › Connect Claude (`AssistantEndpoint.connectCode`, eight Crockford characters, ten minutes, once), typed on the authorization page; codes and tokens are kept as SHA-256 hashes. The handlers take plain requests (`ConnectorRequest`), so `test/integration/assistant/` drives the whole flow without a web server; the Relic routes only translate.
|
||||
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (`core/sync/`, provided by `PersonApp`) hands to the trouble dot on the Settings cogs (on Home and Profile; no tab carries it), the Sync card in Settings and the banner on Home. Only the last outcome counts: one sync that goes through clears a failure. It also counts the changes not on the server yet (`pending`): every save asks for a sync (`requestSync`), and a sync that goes through takes those saved before it started; the engine can't say, since the server only tells it at the start of a sync what it has. The count is kept in `sync_pending.json` in the person's folder (`PendingChangesStore`) and shown on the Sync card, beside whether a sync ever went through on this device (`syncedHere`, read as the person's data opens): until one has, an empty library says it is on its way from the instance rather than that there are no studysets (decided 2026-10-17: a first sync brings everything at once, at its end). The Library's Refresh and pulling it down sync now (`syncAndWait`, which a pull's spinner waits on).
|
||||
- **Serverpod's version moves together with `serverpod_offline_sync`**, which allows only a narrow range of Serverpod (v0.0.10: `>=4.0.4 <4.1.0`; v0.0.8 pinned v4.0.0 exactly, which the root `pubspec.yaml` overrode until 2026-10-09). Every package pins Serverpod's packages to one version: upgrade them all together with the sync packages and the CLI version CI generates code with (`SERVERPOD_VERSION` in `.forgejo/workflows/ci.yaml`), after reading the sync package's changelog. Never override a version the sync package doesn't allow without checking what changed in `serverpod_database`, which it builds on. Its shared part, `serverpod_offline_sync`, comes from our fork (the root `pubspec.yaml`'s `dependency_overrides`, `project-halkyon/serverpod-offline-sync` on code.halkyon.app, pinned to a signed commit of branch `halkyon/batch-inserts`, which stacks six branches): `halkyon/byte-payloads` (decided 2026-10-12: v0.0.10 compares byte payloads byte by byte through a UUID parse that throws for each byte, so one sync touching a few thousand cards blocked the server for minutes); `halkyon/inbound-merges` (2026-10-17: `onInboundMerge`, called only when changes from the remote peer were merged, which the app's sync uses; `onMergeSuccess` fires for a space only pushed to, so every repository read everything again after each save's sync); `halkyon/fast-deletes` (2026-10-17: a local delete skips the foreign key projection pass when it can't change it, `canSkipProjectionAfterLocalDelete`; the pass loaded nearly every synced row, 8 s for one delete in a library of 8,000 cards, 30 s for a card; not reported upstream yet, the developer's call); `halkyon/fast-merges` (2026-10-17: a merge only inserting new rows proven unable to change the projection skips its passes, `canLeaveProjectionUnchangedByMergedInserts`, 9.5 s to 60 ms for a rating another device made in such a library; and projections are rebuilt once after the synced tables changed, not on every session and merge, which fixes #150 below); `halkyon/fast-merge-deletes` (2026-10-17: a merge only deleting rows skips the pass when every visible row below them goes with them, `canLeaveProjectionUnchangedByMergedDeletes`: a card deleted on one device took the server 5.4 s to merge and each other device 4 s, now 35 and 99 ms); `halkyon/batch-inserts` (2026-10-17: such a merge writes its new rows up to 1,000 to a statement, and the insert proof takes a parent of the row's own table inserted before it, as nested studysets are: a first push of 8,000 cards took the server 35 to 41 s, now about 5; a device receiving it 60 s, now 44, as Serverpod's SQLite adapter still inserts row by row). Move the fork along when upgrading, and drop each change once a release has it.
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@ SOURCE_URL=
|
|||
# Jellyfin server answers there; `none`: no suggestion.
|
||||
JELLYFIN_URL=
|
||||
|
||||
# Where this instance keeps people's data, and its backups, which the app shows as a flag
|
||||
# and in words when someone chooses an instance: a country's code (ISO 3166-1 alpha-2,
|
||||
# `CH`) or `EU`; for the backups also `none`. Where the disks are, not which law governs
|
||||
# you as the operator: claim only that. Empty: the app says nothing.
|
||||
DATA_LOCATION=
|
||||
BACKUP_LOCATION=
|
||||
|
||||
# How much room people's images and sounds may take on this instance, in MiB. Empty: the
|
||||
# defaults, 100 for an anonymous person (anonymous accounts are meant to become identified
|
||||
# ones), 500 for one signed in with GitHub, and 15000 for everyone together (0: no limit
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ Once, in this order. The commands name who runs them, and where.
|
|||
|
||||
### 1. The Storage Box (you, in Hetzner Console)
|
||||
|
||||
1. Create a Storage Box (BX11, 1 TB, is plenty) in any location, with a password, which
|
||||
1. Create a Storage Box (BX11, 1 TB, is plenty) in any location (Germany and Finland are
|
||||
in the EU; the app shows where, from `BACKUP_LOCATION` in `deploy/.env`, so set it to
|
||||
the box's: `EU` there), with a password, which
|
||||
goes into your password manager. Turn on **SSH Support** (port 23, which restic uses)
|
||||
and **External Reachability**: without it, the box only answers inside Hetzner's
|
||||
network. Samba and WebDAV stay off; FTP and SFTP on port 22 can't be turned off.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ services:
|
|||
# The Jellyfin server suggested to people; empty: jellyfin. on the
|
||||
# instance's own domain, if one answers there; `none`: none.
|
||||
JELLYFIN_URL: ${JELLYFIN_URL:-}
|
||||
# Where people's data and the backups are, which the app shows; empty:
|
||||
# it says nothing.
|
||||
DATA_LOCATION: ${DATA_LOCATION:-}
|
||||
BACKUP_LOCATION: ${BACKUP_LOCATION:-}
|
||||
# How much room people's files may take, in MiB; empty: the defaults.
|
||||
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
||||
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
||||
|
|
|
|||
|
|
@ -926,9 +926,10 @@ class EndpointInstance extends _isc.EndpointRef {
|
|||
String get name => 'instance';
|
||||
|
||||
/// This instance's offer: GitHub sign-in only with the operator's OAuth
|
||||
/// app, email sign-in only where it can mail codes, where its source is, the oldest app build it works with, the
|
||||
/// domain its people's handles are on, where assistants connect, and the
|
||||
/// Jellyfin server it suggests.
|
||||
/// app, email sign-in only where it can mail codes, where its source is,
|
||||
/// the oldest app build it works with, the domain its people's handles
|
||||
/// are on, where assistants connect, the Jellyfin server it suggests, and
|
||||
/// where it keeps people's data and its backups.
|
||||
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
|
||||
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
|
||||
'instance',
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ abstract class InstanceInfo
|
|||
this.assistantConnectorUrl,
|
||||
this.jellyfinUrl,
|
||||
this.emailSignIn,
|
||||
this.dataLocation,
|
||||
this.backupLocation,
|
||||
});
|
||||
|
||||
factory InstanceInfo({
|
||||
|
|
@ -34,6 +36,8 @@ abstract class InstanceInfo
|
|||
String? assistantConnectorUrl,
|
||||
String? jellyfinUrl,
|
||||
bool? emailSignIn,
|
||||
String? dataLocation,
|
||||
String? backupLocation,
|
||||
}) = _InstanceInfoImpl;
|
||||
|
||||
factory InstanceInfo.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
|
|
@ -48,6 +52,8 @@ abstract class InstanceInfo
|
|||
emailSignIn: jsonSerialization['emailSignIn'] == null
|
||||
? null
|
||||
: _isc.BoolJsonExtension.fromJson(jsonSerialization['emailSignIn']),
|
||||
dataLocation: jsonSerialization['dataLocation'] as String?,
|
||||
backupLocation: jsonSerialization['backupLocation'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +91,16 @@ abstract class InstanceInfo
|
|||
/// code mailed to it. Null from servers that predate it: no.
|
||||
bool? emailSignIn;
|
||||
|
||||
/// Where this instance keeps people's data, as its operator says
|
||||
/// (DATA_LOCATION, decided 2026-10-18): a country's code (ISO 3166-1
|
||||
/// alpha-2, `CH`) or `EU`; where the disks are, not which law governs.
|
||||
/// Null where the operator says nothing, and from servers that predate it.
|
||||
String? dataLocation;
|
||||
|
||||
/// Where it keeps its backups (BACKUP_LOCATION): as [dataLocation], or
|
||||
/// `none` where it keeps none. Null where the operator says nothing.
|
||||
String? backupLocation;
|
||||
|
||||
/// Returns a shallow copy of this [InstanceInfo]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_isc.useResult
|
||||
|
|
@ -96,6 +112,8 @@ abstract class InstanceInfo
|
|||
String? assistantConnectorUrl,
|
||||
String? jellyfinUrl,
|
||||
bool? emailSignIn,
|
||||
String? dataLocation,
|
||||
String? backupLocation,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -109,6 +127,8 @@ abstract class InstanceInfo
|
|||
'assistantConnectorUrl': assistantConnectorUrl,
|
||||
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
|
||||
if (emailSignIn != null) 'emailSignIn': emailSignIn,
|
||||
if (dataLocation != null) 'dataLocation': dataLocation,
|
||||
if (backupLocation != null) 'backupLocation': backupLocation,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +144,8 @@ abstract class InstanceInfo
|
|||
'assistantConnectorUrl': assistantConnectorUrl,
|
||||
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
|
||||
if (emailSignIn != null) 'emailSignIn': emailSignIn,
|
||||
if (dataLocation != null) 'dataLocation': dataLocation,
|
||||
if (backupLocation != null) 'backupLocation': backupLocation,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +166,8 @@ class _InstanceInfoImpl extends InstanceInfo {
|
|||
String? assistantConnectorUrl,
|
||||
String? jellyfinUrl,
|
||||
bool? emailSignIn,
|
||||
String? dataLocation,
|
||||
String? backupLocation,
|
||||
}) : super._(
|
||||
gitHubClientId: gitHubClientId,
|
||||
sourceCodeUrl: sourceCodeUrl,
|
||||
|
|
@ -152,6 +176,8 @@ class _InstanceInfoImpl extends InstanceInfo {
|
|||
assistantConnectorUrl: assistantConnectorUrl,
|
||||
jellyfinUrl: jellyfinUrl,
|
||||
emailSignIn: emailSignIn,
|
||||
dataLocation: dataLocation,
|
||||
backupLocation: backupLocation,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [InstanceInfo]
|
||||
|
|
@ -166,6 +192,8 @@ class _InstanceInfoImpl extends InstanceInfo {
|
|||
Object? assistantConnectorUrl = _Undefined,
|
||||
Object? jellyfinUrl = _Undefined,
|
||||
Object? emailSignIn = _Undefined,
|
||||
Object? dataLocation = _Undefined,
|
||||
Object? backupLocation = _Undefined,
|
||||
}) {
|
||||
return InstanceInfo(
|
||||
gitHubClientId: gitHubClientId is String?
|
||||
|
|
@ -183,6 +211,10 @@ class _InstanceInfoImpl extends InstanceInfo {
|
|||
: this.assistantConnectorUrl,
|
||||
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
|
||||
emailSignIn: emailSignIn is bool? ? emailSignIn : this.emailSignIn,
|
||||
dataLocation: dataLocation is String? ? dataLocation : this.dataLocation,
|
||||
backupLocation: backupLocation is String?
|
||||
? backupLocation
|
||||
: this.backupLocation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
halkyon_learn_flutter/assets/flags/LICENSE-flag-icons.txt
Normal file
21
halkyon_learn_flutter/assets/flags/LICENSE-flag-icons.txt
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013 Panayiotis Lipiridis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS 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. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -589,6 +589,11 @@
|
|||
"instanceBuiltInTagline": "In der App voreingestellt",
|
||||
"instanceAnother": "Andere Instanz",
|
||||
"instanceAnotherTagline": "Gib ihre Adresse ein",
|
||||
"instanceDataAndBackupsIn": "Daten und Sicherungen: {place}",
|
||||
"instanceDataIn": "Daten: {place}",
|
||||
"instanceBackupsIn": "Sicherungen: {place}",
|
||||
"instanceNoBackups": "Keine Sicherungen",
|
||||
"instanceEuropeanUnion": "Europäische Union",
|
||||
"instanceContinue": "Weiter",
|
||||
"instanceAddressRequired": "Gib die Adresse einer Instanz ein.",
|
||||
"instanceAddressInvalid": "Das ist keine Adresse.",
|
||||
|
|
|
|||
|
|
@ -668,6 +668,14 @@
|
|||
"instanceBuiltInTagline": "Comes with the app",
|
||||
"instanceAnother": "Another instance",
|
||||
"instanceAnotherTagline": "Enter its address",
|
||||
"instanceDataAndBackupsIn": "Data and backups: {place}",
|
||||
"@instanceDataAndBackupsIn": {"placeholders": {"place": {"type": "String"}}},
|
||||
"instanceDataIn": "Data: {place}",
|
||||
"@instanceDataIn": {"placeholders": {"place": {"type": "String"}}},
|
||||
"instanceBackupsIn": "Backups: {place}",
|
||||
"@instanceBackupsIn": {"placeholders": {"place": {"type": "String"}}},
|
||||
"instanceNoBackups": "No backups",
|
||||
"instanceEuropeanUnion": "European Union",
|
||||
"instanceContinue": "Continue",
|
||||
"instanceAddressRequired": "Enter the address of an instance.",
|
||||
"instanceAddressInvalid": "This isn't an address.",
|
||||
|
|
|
|||
|
|
@ -3526,6 +3526,36 @@ abstract class AppLocalizations {
|
|||
/// **'Enter its address'**
|
||||
String get instanceAnotherTagline;
|
||||
|
||||
/// No description provided for @instanceDataAndBackupsIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Data and backups: {place}'**
|
||||
String instanceDataAndBackupsIn(String place);
|
||||
|
||||
/// No description provided for @instanceDataIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Data: {place}'**
|
||||
String instanceDataIn(String place);
|
||||
|
||||
/// No description provided for @instanceBackupsIn.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Backups: {place}'**
|
||||
String instanceBackupsIn(String place);
|
||||
|
||||
/// No description provided for @instanceNoBackups.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No backups'**
|
||||
String get instanceNoBackups;
|
||||
|
||||
/// No description provided for @instanceEuropeanUnion.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'European Union'**
|
||||
String get instanceEuropeanUnion;
|
||||
|
||||
/// No description provided for @instanceContinue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2157,6 +2157,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get instanceAnotherTagline => 'Gib ihre Adresse ein';
|
||||
|
||||
@override
|
||||
String instanceDataAndBackupsIn(String place) {
|
||||
return 'Daten und Sicherungen: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String instanceDataIn(String place) {
|
||||
return 'Daten: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String instanceBackupsIn(String place) {
|
||||
return 'Sicherungen: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String get instanceNoBackups => 'Keine Sicherungen';
|
||||
|
||||
@override
|
||||
String get instanceEuropeanUnion => 'Europäische Union';
|
||||
|
||||
@override
|
||||
String get instanceContinue => 'Weiter';
|
||||
|
||||
|
|
|
|||
|
|
@ -2137,6 +2137,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get instanceAnotherTagline => 'Enter its address';
|
||||
|
||||
@override
|
||||
String instanceDataAndBackupsIn(String place) {
|
||||
return 'Data and backups: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String instanceDataIn(String place) {
|
||||
return 'Data: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String instanceBackupsIn(String place) {
|
||||
return 'Backups: $place';
|
||||
}
|
||||
|
||||
@override
|
||||
String get instanceNoBackups => 'No backups';
|
||||
|
||||
@override
|
||||
String get instanceEuropeanUnion => 'European Union';
|
||||
|
||||
@override
|
||||
String get instanceContinue => 'Continue';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/src/app/instance_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/app/person_app.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/bloc/app_bloc_observer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/country/country_names.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
|
||||
|
|
@ -13,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/mecab_license.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/geo_map/data/cldr_license.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/instance/data/flag_icons_license.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/kanji/data/kanjivg_license.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
|
@ -23,6 +25,7 @@ Future<void> main() async {
|
|||
Bloc.observer = const AppBlocObserver();
|
||||
registerAppFontLicenses();
|
||||
registerKanjiVgLicense();
|
||||
registerFlagIconsLicense();
|
||||
registerCldrLicense();
|
||||
registerMecabLicenses();
|
||||
// The instance the build names (`--dart-define=SERVER_URL=…`, or
|
||||
|
|
@ -40,6 +43,7 @@ Future<void> main() async {
|
|||
InstanceGate(
|
||||
choice: instances,
|
||||
checker: ServerpodInstanceChecker(),
|
||||
countryNames: AssetCountryNames(),
|
||||
open: (instance) => Dependencies.create(instance, device),
|
||||
deviceSettings: device.deviceSettingsStore,
|
||||
app: SessionGate(app: (person) => PersonApp(person: person)),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/country/country_names.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/data/device_settings_store.dart';
|
||||
|
|
@ -29,6 +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,
|
||||
|
||||
/// The dependencies of the app on an instance.
|
||||
required final Future<Dependencies> Function(Instance instance) open,
|
||||
|
||||
|
|
@ -126,6 +130,7 @@ class _InstanceGateState() extends State<InstanceGate> {
|
|||
return InstanceScope(
|
||||
notifier: widget.choice,
|
||||
checker: widget.checker,
|
||||
countryNames: widget.countryNames,
|
||||
child: switch (current) {
|
||||
null => _PlainApp(look: _look, home: const FirstStartScreen()),
|
||||
final instance when dependencies != null && _opened == instance =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Countries' names by their code (ISO 3166-1 alpha-2), in a language.
|
||||
/// Tests replace it 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);
|
||||
}
|
||||
|
||||
/// The world map's names, Unicode CLDR's
|
||||
/// (`assets/maps/world_countries.geojson`, whose notice the licence list
|
||||
/// holds), read when first asked for.
|
||||
class AssetCountryNames([final AssetBundle? _bundle]) implements CountryNames {
|
||||
/// The world map, which the map questions read too.
|
||||
static const asset = 'assets/maps/world_countries.geojson';
|
||||
|
||||
// The names themselves, not the future reading them (AGENTS.md, Testing).
|
||||
Map<String, Map<String, String>>? _names;
|
||||
|
||||
@override
|
||||
Future<String?> nameOf(String code, String language) async {
|
||||
final names = _names ??= await _read();
|
||||
return _nameIn(names, code, language);
|
||||
}
|
||||
|
||||
Future<Map<String, Map<String, String>>> _read() async {
|
||||
final geojson = await (_bundle ?? rootBundle).loadString(asset);
|
||||
// Some 2 MB of outlines around the names: read where no frame waits.
|
||||
return await Isolate.run(() => namesIn(geojson));
|
||||
}
|
||||
}
|
||||
|
||||
/// The names given, as a test gives them.
|
||||
class MemoryCountryNames(final Map<String, Map<String, String>> _names)
|
||||
implements CountryNames {
|
||||
@override
|
||||
Future<String?> nameOf(String code, String language) async =>
|
||||
_nameIn(_names, code, language);
|
||||
}
|
||||
|
||||
String? _nameIn(
|
||||
Map<String, Map<String, String>> names,
|
||||
String code,
|
||||
String language,
|
||||
) {
|
||||
final of = names[code.toUpperCase()];
|
||||
return of?[language] ?? of?['en'];
|
||||
}
|
||||
|
||||
/// The names of the countries in [geojson], a map's features, by code;
|
||||
/// those without a code (Natural Earth's `-99`) left out.
|
||||
Map<String, Map<String, String>> namesIn(String geojson) {
|
||||
final features =
|
||||
(jsonDecode(geojson) as Map<String, Object?>)['features']!
|
||||
as List<Object?>;
|
||||
return {
|
||||
for (final feature in features.cast<Map<String, Object?>>())
|
||||
if (feature['properties']
|
||||
case {
|
||||
'code': final String code,
|
||||
'names': final Map<String, Object?> names,
|
||||
}
|
||||
when RegExp(r'^[A-Z]{2}$').hasMatch(code))
|
||||
code: names.cast<String, String>(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,24 +1,42 @@
|
|||
import 'package:halkyon_learn_client/halkyon_learn_client.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_locations.dart';
|
||||
|
||||
/// Whether an instance of Halkyon Learn answers at an address, before the
|
||||
/// app moves there. Tests replace it with a fake.
|
||||
/// Asks instances of Halkyon Learn about themselves before the app moves
|
||||
/// there: whether one answers at an address, and where it keeps people's
|
||||
/// data. Tests replace it with a fake.
|
||||
abstract interface class InstanceChecker() {
|
||||
/// Whether [instance] answers as an instance of Halkyon Learn.
|
||||
Future<bool> answers(Instance instance);
|
||||
|
||||
/// Where [instance] says it keeps people's data and its backups; null
|
||||
/// where it doesn't answer.
|
||||
Future<InstanceLocations?> locationsOf(Instance instance);
|
||||
}
|
||||
|
||||
/// Asks the instance about itself (`instance.info()`), with a client of its
|
||||
/// own that holds no session.
|
||||
class ServerpodInstanceChecker() implements InstanceChecker {
|
||||
@override
|
||||
Future<bool> answers(Instance instance) async {
|
||||
Future<bool> answers(Instance instance) async =>
|
||||
await _info(instance) != null;
|
||||
|
||||
@override
|
||||
Future<InstanceLocations?> locationsOf(Instance instance) async =>
|
||||
switch (await _info(instance)) {
|
||||
final info? => InstanceLocations(
|
||||
data: info.dataLocation,
|
||||
backups: info.backupLocation,
|
||||
),
|
||||
null => null,
|
||||
};
|
||||
|
||||
Future<InstanceInfo?> _info(Instance instance) async {
|
||||
final client = Client('${instance.url}');
|
||||
try {
|
||||
await client.instance.info().timeout(const Duration(seconds: 15));
|
||||
return true;
|
||||
return await client.instance.info().timeout(const Duration(seconds: 15));
|
||||
} on Object {
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Where an instance keeps people's data and its backups, as its operator
|
||||
/// says (`instance.info()`, decided 2026-10-18): 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.
|
||||
final class const InstanceLocations({
|
||||
/// Where people's data is.
|
||||
final String? data,
|
||||
|
||||
/// Where the backups are, or [noBackups].
|
||||
final String? backups,
|
||||
}) with Equatable {
|
||||
/// What [backups] says of an instance keeping none.
|
||||
static const noBackups = 'none';
|
||||
|
||||
/// The European Union's code, ISO 3166's reserved one.
|
||||
static const europeanUnion = 'EU';
|
||||
|
||||
@override
|
||||
List<Object?> get props => [data, backups];
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/country/country_names.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_checker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance_choice.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -9,6 +10,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,
|
||||
required super.child,
|
||||
super.key,
|
||||
}) extends InheritedNotifier<InstanceChoice> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Adds the flags' notice to the app's licence list, beside the packages'
|
||||
/// own: the flag package draws lipis' flag-icons (v7.5.0), whose MIT
|
||||
/// licence asks that its notice go with every copy, and the package's own
|
||||
/// licence names only the package.
|
||||
void registerFlagIconsLicense() => LicenseRegistry.addLicense(() async* {
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'flag-icons',
|
||||
], await rootBundle.loadString('assets/flags/LICENSE-flag-icons.txt'));
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flag/flag.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/instance/instance.dart';
|
||||
|
|
@ -8,14 +9,16 @@ import 'package:halkyon_learn_flutter/src/core/instance/instance_scope.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/bloc/session_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_location.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/instance/widget/instance_step.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The accounts on this device, one per instance, the one in use marked, as
|
||||
/// Mastodon's apps list them: tapping another switches to it, its trailing
|
||||
/// button removes it from the device, and Add account adds one on another
|
||||
/// instance.
|
||||
/// Mastodon's apps list them, each with where its instance keeps people's
|
||||
/// data once it says (its flag in place of the initial): tapping another
|
||||
/// switches to it, its trailing button removes it from the device, and Add
|
||||
/// account adds one on another instance.
|
||||
Future<void> showAccountSwitcher(BuildContext context) => showAdaptiveSheet(
|
||||
context,
|
||||
builder: (sheetContext) => const _AccountSwitcherSheet(),
|
||||
|
|
@ -67,34 +70,44 @@ class const _AccountTile({
|
|||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final choice = InstanceScope.maybeOf(context)!.choice;
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
child: Text(instance.name.characters.first.toUpperCase()),
|
||||
// Which account is in use there, while someone is signed in.
|
||||
final account = switch ((inUse, context.watch<SessionBloc>().state)) {
|
||||
(true, SessionStateActive(:final person)) =>
|
||||
person.isIdentified ? l10n.accountSignedIn : l10n.accountAnonymous,
|
||||
_ => null,
|
||||
};
|
||||
return InstanceLocationBuilder(
|
||||
instance: instance,
|
||||
builder: (context, flag, words) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
// The flag of where the instance keeps people's data, once it says.
|
||||
leading: flag != null && Flag.flagsCode.contains(flag.toLowerCase())
|
||||
? InstanceFlag(flag, size: 40)
|
||||
: CircleAvatar(
|
||||
child: Text(instance.name.characters.first.toUpperCase()),
|
||||
),
|
||||
title: Text(instance.name),
|
||||
subtitle: switch ([?account, ?words]) {
|
||||
[] => null,
|
||||
final lines => Text(lines.join('\n')),
|
||||
},
|
||||
isThreeLine: account != null && (words?.contains('\n') ?? false),
|
||||
trailing: inUse
|
||||
? Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary)
|
||||
: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: l10n.accountRemoveTooltip,
|
||||
onPressed: () => _remove(context, choice),
|
||||
),
|
||||
// The sheet goes first, so the app rebuilding on the other instance
|
||||
// doesn't meet an open route.
|
||||
onTap: inUse
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(choice.use(instance));
|
||||
},
|
||||
),
|
||||
title: Text(instance.name),
|
||||
// Which account is in use there, while someone is signed in.
|
||||
subtitle: switch ((inUse, context.watch<SessionBloc>().state)) {
|
||||
(true, SessionStateActive(:final person)) => Text(
|
||||
person.isIdentified ? l10n.accountSignedIn : l10n.accountAnonymous,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
trailing: inUse
|
||||
? Icon(LucideIcons.circleCheck, color: theme.colorScheme.primary)
|
||||
: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: l10n.accountRemoveTooltip,
|
||||
onPressed: () => _remove(context, choice),
|
||||
),
|
||||
// The sheet goes first, so the app rebuilding on the other instance
|
||||
// doesn't meet an open route.
|
||||
onTap: inUse
|
||||
? null
|
||||
: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(choice.use(instance));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue