feat(app,learn,todo): the operator's "This instance" card, screen and badge (ADR 0021, step 1)
Whoever operates the instance approves, refuses and suspends accounts
and names other operators in the app (D21 to D24, D34):
- halkyon_app: InstanceOperatorRepository on
ServerpodInstanceOperatorGateway (the module's instanceOperator
endpoint; refusals as OperatorFailed: notFound, isOperator,
lastOperator, notAllowed for 401 and 403, failed), in AppDependencies.
OperatingCubit counts the accounts that wait: asked as the person's
app opens, after every change, and every 5 minutes only while they
operate the instance, so nobody else's app keeps asking.
InstanceOperatorCard (or its tiles) and WaitingAccountsBadge show
nothing at all for anyone else. InstanceOperatorScreen, laid out as
Settings: the accounts that wait, each showing its four words to read
out before Approve (D33), Refuse for new ones after asking; who
operates, Take back after asking, the last always kept; Name an
operator by handle; Find an account by the start of its handle, with
Suspend after asking and Name as operator. English and German texts
("Admin" as the role word in German).
- Learn: the card right after the Sync card in Settings, which the
cogs' count leads to as their dot leads to Sync; the count on the
Settings cogs of Home and Profile.
- Todo: the tiles in the account sheet and the count on its button.
Todo's server is a member, whose `operating()` is always null, so
nothing shows there until it holds the register.
Tests: the gateway's mapping, the repository, both cubits with their
denials, the card, the badge and the screen; Learn's Settings shows the
card and the count to an operator and neither to anyone else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7ac715cd97
commit
31b9c17850
41 changed files with 2999 additions and 28 deletions
|
|
@ -9,8 +9,8 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
|
||||||
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
|
- **`apps/<app>/`**: each app's packages, as `serverpod create` lays them out (`halkyon_<app>_server`, `_client`, `_flutter`), plus what is the app's alone (Learn's curator, the scripts writing Learn's assets in `apps/learn/tool/`, run from `apps/learn`). Learn is the app that runs; Todo is still Serverpod's scaffold; Train and Meditate are to come.
|
||||||
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
- **`packages/`**: what the apps share, each with its README and licence, which the apps take by path (`path: ../../../packages/<package>`), so a change to a shared package and to the apps using it is one commit:
|
||||||
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
- `halkyon_ui`: Halkyon's look, the theme around cobalt and copper, Noto Sans bundled, the window-size classes and the widgets every screen is built of, with texts of its own (English, German).
|
||||||
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
- `halkyon_base_server`, `halkyon_base_client`: the Serverpod module every Halkyon server includes (ADR 0014): signing in (a password with TOTP and the resets of ADR 0017, SimpleLogin on `halkyon_oidc`, email codes proving an address; no anonymous accounts since ADR 0020, D25, nor GitHub since D36), an account's ways in and its deletion with a hook for each app's own data, the pruning, the instance's basics, people's handles and public profiles, who may sign up (`SIGN_UP`: open, approval, closed) with the accounts waiting for approval and the instance's operators who approve them (Halkyon Learn's ADR 0021, step 1), and the instance's register (ADR 0016): one account per person across an operator's services, held by one service (Learn's server on learn.halkyon.eu) and asked by the others, its members (`ACCOUNT_REGISTER_URL`), all sharing a secret (`INSTANCE_SECRET`); its README says how.
|
||||||
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
|
- `halkyon_app`: every app's shell on the module's client (ADR 0014): the instances a device keeps accounts on and the first start, signing in and switching person, the gates building an app's dependencies per instance and per person, the sync service and where it stands, the account section of an app's settings (`AccountTiles`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), About with the instance's source link (`showAppAbout`), the language every app picks (`resolveAppLocale`) and countries' and subdivisions' names (Unicode CLDR's, bundled), with texts that name the app.
|
||||||
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
- `halkyon_core` (low-level primitives in pure Dart: ids, a logger, `SerialQueue`, `CoalescingRunner`) and `halkyon_interop` (contracts between apps, pure Dart).
|
||||||
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
|
- `halkyon_keys` (pure Dart on libsodium, ADR 0017): an account's keys on the device, the password's Argon2id and SRP's x, the key pairs and their wrapping, the recovery phrase, data keys, content sealed and signed. SRP-6a's two halves are the module's (`halkyon_base_server`'s `base.dart`, `halkyon_base_client`'s `srp.dart`), as are TOTP and the recovery codes. `sodium`'s build hook compiles libsodium, so every machine that builds or tests needs a C compiler and `make`.
|
||||||
- `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first.
|
- `halkyon_oidc_server`, `halkyon_oidc_client` (ADR 0023): signing in with OpenID Connect, with a SimpleLogin preset, as a Serverpod module that halkyon_base builds on; `halkyon_oidc_flutter`, the device's half: the provider's page opened, the way back caught (the app's scheme, or the loopback on Linux and Windows, `LoopbackWebAuth`) and its state checked. Nothing of Halkyon's is in it, so that others can use it with Serverpod: under BSD-3-Clause, Serverpod's own licence, where the other packages are AGPL; `publish_to: none` until it is published to pub.dev, which is asked again first.
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,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.
|
- **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 `apps/learn/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.
|
- **The client's local migration** (in `apps/learn/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, which Learn's configuration of halkyon_base carries; 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` (halkyon_app), 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. It is 24 since GitHub went (ADR 0020, D36): builds up to 23 (v0.20.0) read whether an account has GitHub (`SignInWays.gitHub`), which is gone, so **the server without GitHub deploys only together with the release of build 24** (as the server with the module did with build 21, ADR 0014); Learn's own `instance.info()` stays, with every field, as released apps read `minimumAppBuild` there.
|
- **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, which Learn's configuration of halkyon_base carries; 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` (halkyon_app), 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. It is 24 since GitHub went (ADR 0020, D36): builds up to 23 (v0.20.0) read whether an account has GitHub (`SignInWays.gitHub`), which is gone, so **the server without GitHub deploys only together with the release of build 24** (as the server with the module did with build 21, ADR 0014); Learn's own `instance.info()` stays, with every field, as released apps read `minimumAppBuild` there.
|
||||||
- **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 (halkyon_app's instances, ADR 0014; Learn gives them `learnFrame`, its words and look). `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 halkyon_base's `instance.basics()`, which must name the app `learn` (`ServerpodInstanceChecker(app: 'learn')`: another Halkyon app's instance is refused), and only over HTTPS or plain HTTP on this machine (`Instance.isSecure`, ADR 0017: an address over plain HTTP elsewhere is refused before anything is sent there, and one kept from before is never opened, the app offering another instance or removing it; development builds keep their own over HTTP, as an emulator reaches the developer's machine as 10.0.2.2); `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)` (`main.dart`) 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. **While nobody is signed in, the app asks how to start** (decided 2026-10-16, `StartScreen`; without anonymous accounts since ADR 0020, D25): on the instance in use, with SimpleLogin or a password, or on another instance; an instance out of reach is said so, with Try again, and one offering no way to create an account too. A first start leads there once the instance is chosen, and so do signing out and a session the server ended. An anonymous account's session, left from before, is deleted as the app starts. Settings names the account's instance ("Signed in 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`: `EU`, a country's code or a subdivision's, ISO 3166-2 such as `US-CA`, the backups also `none`, decided 2026-10-19; where the disks are, never which law governs, as a court reaches the operator wherever the server stands), halkyon_base's `instance.basics()` hands it on (Learn's `instance.info()` too), and the first start's tile and Switch account show it once the instance answers (`InstanceLocationBuilder`): the most specific flag of where the data is (`InstanceFlag`, from the `flag` package, which draws lipis' flag-icons: a subdivision's own where it has one, Scotland's, else its country's) and the words, as labels rather than sentences ("Data: European Union", "Data: Germany (EU)", "Backups: California, United States"), since "in" would need the article some countries take; a member country of the EU gets "(EU)" and a subdivision its country; countries are named by Unicode CLDR 48.2's names that halkyon_app bundles (`AssetCountryNames`, the same as the world map's, Hong Kong, Macao, Myanmar and Palestine by their short names), subdivisions by Unicode CLDR 48.2's, bundled in halkyon_app; `registerHalkyonAppLicenses` registers the flags' notice and that CLDR's, beside Learn's own for the map.
|
- **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 (halkyon_app's instances, ADR 0014; Learn gives them `learnFrame`, its words and look). `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 halkyon_base's `instance.basics()`, which must name the app `learn` (`ServerpodInstanceChecker(app: 'learn')`: another Halkyon app's instance is refused), and only over HTTPS or plain HTTP on this machine (`Instance.isSecure`, ADR 0017: an address over plain HTTP elsewhere is refused before anything is sent there, and one kept from before is never opened, the app offering another instance or removing it; development builds keep their own over HTTP, as an emulator reaches the developer's machine as 10.0.2.2); `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)` (`main.dart`) 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. **While nobody is signed in, the app asks how to start** (decided 2026-10-16, `StartScreen`; without anonymous accounts since ADR 0020, D25): on the instance in use, with SimpleLogin or a password, or on another instance; an instance out of reach is said so, with Try again, and one offering no way to create an account too. A first start leads there once the instance is chosen, and so do signing out and a session the server ended. **Where the operator approves sign-ups** (ADR 0021, step 1, decided 2026-10-25: `SIGN_UP=approval` in `deploy/.env`, the start screen saying so first), a new account gets no session but halkyon_app's wait screen, with four words the operator reads out to the person to match before approving, and signs in once more once approved; whoever operates the instance gets the card This instance in Settings, right after the Sync card, and the count of accounts waiting on the Settings cogs (Home, Profile), leading to the operator's screen (approving, refusing, suspending, naming operators). The first operator is named by SQL (halkyon_base's README). An anonymous account's session, left from before, is deleted as the app starts. Settings names the account's instance ("Signed in 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`: `EU`, a country's code or a subdivision's, ISO 3166-2 such as `US-CA`, the backups also `none`, decided 2026-10-19; where the disks are, never which law governs, as a court reaches the operator wherever the server stands), halkyon_base's `instance.basics()` hands it on (Learn's `instance.info()` too), and the first start's tile and Switch account show it once the instance answers (`InstanceLocationBuilder`): the most specific flag of where the data is (`InstanceFlag`, from the `flag` package, which draws lipis' flag-icons: a subdivision's own where it has one, Scotland's, else its country's) and the words, as labels rather than sentences ("Data: European Union", "Data: Germany (EU)", "Backups: California, United States"), since "in" would need the article some countries take; a member country of the EU gets "(EU)" and a subdivision its country; countries are named by Unicode CLDR 48.2's names that halkyon_app bundles (`AssetCountryNames`, the same as the world map's, Hong Kong, Macao, Myanmar and Palestine by their short names), subdivisions by Unicode CLDR 48.2's, bundled in halkyon_app; `registerHalkyonAppLicenses` registers the flags' notice and that CLDR's, beside Learn's own for the map.
|
||||||
- **Searching the web** (decided 2026-10-10; `feature/assistant/`; the hand-off to AI assistants and Claude's connector went on 23 Oct 2026, ADR 0022): a device setting, off unless the person turns it on (Settings › Web search), 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). Above the field, chips offer the parts of what it is about that a search takes (`AssistantTopic.parts`, those `searchable`: a card's front, back, each hint, its explanation; never a studyset's name or description), none chosen, so only what the person picks goes along, each cut at 600 characters. `WebSearch` opens the chosen engine's results for the question and the chosen text (`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 them to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Its names keep "assistant": the assistant of ADR 0021 (accepted 25 Oct 2026, to be built for v0.21.0), on an instance whose operator grants it, is to answer in the same field. Until then Halkyon calls no language model, and nothing on an instance reads people's content for one (ADR 0022): with API keys on a public instance anyone could drive up the bill, and each person decides what a model sees.
|
- **Searching the web** (decided 2026-10-10; `feature/assistant/`; the hand-off to AI assistants and Claude's connector went on 23 Oct 2026, ADR 0022): a device setting, off unless the person turns it on (Settings › Web search), 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). Above the field, chips offer the parts of what it is about that a search takes (`AssistantTopic.parts`, those `searchable`: a card's front, back, each hint, its explanation; never a studyset's name or description), none chosen, so only what the person picks goes along, each cut at 600 characters. `WebSearch` opens the chosen engine's results for the question and the chosen text (`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 them to the device's own web search (`ACTION_WEB_SEARCH` in `MainActivity.kt`, Brave Search where nothing answers). Its names keep "assistant": the assistant of ADR 0021 (accepted 25 Oct 2026, to be built for v0.21.0), on an instance whose operator grants it, is to answer in the same field. Until then Halkyon calls no language model, and nothing on an instance reads people's content for one (ADR 0022): with API keys on a public instance anyone could drive up the bill, and each person decides what a model sees.
|
||||||
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (halkyon_app, 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).
|
- **Where sync stands** is `SyncService.status` (syncing, the last sync that went through, the last failure; since the app started), which `SyncStatusBloc` (halkyon_app, 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, halkyon_base's too: upgrade them all together with the sync packages, halkyon_base 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.
|
- **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, halkyon_base's too: upgrade them all together with the sync packages, halkyon_base 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.
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,13 @@ class _PersonAppState() extends State<PersonApp> {
|
||||||
PersonDependencies.of(context).discussions,
|
PersonDependencies.of(context).discussions,
|
||||||
)..add(const InboxCountEventRefreshed()),
|
)..add(const InboxCountEventRefreshed()),
|
||||||
),
|
),
|
||||||
|
// How many accounts wait, for whoever operates the
|
||||||
|
// instance: Settings' card and the badge on its cogs.
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) => OperatingCubit(
|
||||||
|
Dependencies.of(context).instanceOperators,
|
||||||
|
),
|
||||||
|
),
|
||||||
// The Pomodoro timer goes on across every screen.
|
// The Pomodoro timer goes on across every screen.
|
||||||
BlocProvider(
|
BlocProvider(
|
||||||
create: (context) => PomodoroBloc(
|
create: (context) => PomodoroBloc(
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,9 @@ class Dependencies({
|
||||||
/// 0020).
|
/// 0020).
|
||||||
@override required final SimpleLoginRepository simpleLogin,
|
@override required final SimpleLoginRepository simpleLogin,
|
||||||
|
|
||||||
|
/// What the instance's operators do there (ADR 0021, D21 to D24).
|
||||||
|
@override required final InstanceOperatorRepository instanceOperators,
|
||||||
|
|
||||||
/// Where the source of what the instance runs is, as it says
|
/// Where the source of what the instance runs is, as it says
|
||||||
/// ([sourceCodeUrl]).
|
/// ([sourceCodeUrl]).
|
||||||
required final Future<String> Function() _sourceCodeUrl,
|
required final Future<String> Function() _sourceCodeUrl,
|
||||||
|
|
@ -255,6 +258,9 @@ class Dependencies({
|
||||||
callbackScheme: 'app.halkyon.learn',
|
callbackScheme: 'app.halkyon.learn',
|
||||||
loopbackPort: 43823,
|
loopbackPort: 43823,
|
||||||
);
|
);
|
||||||
|
final instanceOperators = InstanceOperatorRepository(
|
||||||
|
ServerpodInstanceOperatorGateway(client.modules.base),
|
||||||
|
);
|
||||||
final folders = PersonFolders(device.storage.peopleOf(instance));
|
final folders = PersonFolders(device.storage.peopleOf(instance));
|
||||||
// One person at a time: before the next person's data opens, the previous
|
// One person at a time: before the next person's data opens, the previous
|
||||||
// person's database has closed, so their folder can go. If it doesn't
|
// person's database has closed, so their folder can go. If it doesn't
|
||||||
|
|
@ -271,6 +277,7 @@ class Dependencies({
|
||||||
auth: AuthRepository(authGateway, addresses),
|
auth: AuthRepository(authGateway, addresses),
|
||||||
passwords: passwords,
|
passwords: passwords,
|
||||||
simpleLogin: simpleLogin,
|
simpleLogin: simpleLogin,
|
||||||
|
instanceOperators: instanceOperators,
|
||||||
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
|
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
|
||||||
dictionaries: DictionaryRepository(
|
dictionaries: DictionaryRepository(
|
||||||
ServerpodDictionaryGateway(client),
|
ServerpodDictionaryGateway(client),
|
||||||
|
|
@ -325,6 +332,7 @@ class Dependencies({
|
||||||
await deviceDictionaries.dispose();
|
await deviceDictionaries.dispose();
|
||||||
await passwords.dispose();
|
await passwords.dispose();
|
||||||
await simpleLogin.dispose();
|
await simpleLogin.dispose();
|
||||||
|
await instanceOperators.dispose();
|
||||||
await authGateway.dispose();
|
await authGateway.dispose();
|
||||||
client.close();
|
client.close();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,10 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
|
||||||
title: Text(l10n.profileTitle),
|
title: Text(l10n.profileTitle),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const SyncTroubleBadge(
|
icon: const WaitingAccountsBadge(
|
||||||
child: Icon(LucideIcons.settings),
|
child: SyncTroubleBadge(
|
||||||
|
child: Icon(LucideIcons.settings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
tooltip: l10n.profileSettingsTooltip,
|
tooltip: l10n.profileSettingsTooltip,
|
||||||
onPressed: () => context.push(RoutePaths.settings),
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,10 @@ class _HomeState() extends State<_Home> {
|
||||||
today: today,
|
today: today,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const SyncTroubleBadge(
|
icon: const WaitingAccountsBadge(
|
||||||
child: Icon(LucideIcons.settings),
|
child: SyncTroubleBadge(
|
||||||
|
child: Icon(LucideIcons.settings),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
tooltip: l10n.settingsTitle,
|
tooltip: l10n.settingsTitle,
|
||||||
onPressed: () => context.push(RoutePaths.settings),
|
onPressed: () => context.push(RoutePaths.settings),
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
const SyncStatusCard(),
|
const SyncStatusCard(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
// For whoever operates the instance alone (ADR 0021,
|
||||||
|
// D34): what the cogs' count leads to, so it comes first
|
||||||
|
// after the Sync card their dot leads to.
|
||||||
|
const InstanceOperatorCard(below: 12),
|
||||||
const DeviceSettingsCard(),
|
const DeviceSettingsCard(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const CitationSettingsCard(),
|
const CitationSettingsCard(),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_app/testing.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
import 'package:halkyon_learn_flutter/src/core/citation/model/citation.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
|
@ -379,4 +381,61 @@ void main() {
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('This instance (ADR 0021, D34)', () {
|
||||||
|
FakeInstanceOperatorGateway operatorGateway({required bool operates}) =>
|
||||||
|
FakeInstanceOperatorGateway()
|
||||||
|
..operates = operates
|
||||||
|
..add(
|
||||||
|
OperatedAccount(
|
||||||
|
id: 'ben',
|
||||||
|
handle: 'ben',
|
||||||
|
since: DateTime.utc(2026, 10, 24),
|
||||||
|
waitingSince: DateTime.utc(2026, 10, 24, 9),
|
||||||
|
words: const ['abandon', 'ability', 'able', 'about'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets("an operator's cog counts the accounts that wait, and leads "
|
||||||
|
'to their card, first after the Sync card', (tester) async {
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
testDependencies(operatorGateway: operatorGateway(operates: true)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.bySemanticsLabel(RegExp('1 account waits for approval')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
await tester.tap(find.byTooltip('Settings'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('This instance'), findsOneWidget);
|
||||||
|
expect(find.text('1 account waits for approval'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.text('This instance')).dy,
|
||||||
|
lessThan(tester.getTopLeft(find.text('Appearance')).dy),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Accounts and operators'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('@ben'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('anyone else sees neither the card nor a count', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpApp(
|
||||||
|
tester,
|
||||||
|
testDependencies(operatorGateway: operatorGateway(operates: false)),
|
||||||
|
location: RoutePaths.settings,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('This instance'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.bySemanticsLabel(RegExp('waits? for approval')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,8 @@ Dependencies testDependencies({
|
||||||
AccountKeyStore? accountKeys,
|
AccountKeyStore? accountKeys,
|
||||||
// On the same `authGateway`; none offers SimpleLogin unless given.
|
// On the same `authGateway`; none offers SimpleLogin unless given.
|
||||||
FakeSimpleLoginGateway? simpleLoginGateway,
|
FakeSimpleLoginGateway? simpleLoginGateway,
|
||||||
|
// Nobody operates the instance unless a test says so.
|
||||||
|
InstanceOperatorGateway? operatorGateway,
|
||||||
}) {
|
}) {
|
||||||
final device = deviceDictionaries ?? noDeviceDictionaries();
|
final device = deviceDictionaries ?? noDeviceDictionaries();
|
||||||
final auth = authGateway ?? FakeAuthGateway();
|
final auth = authGateway ?? FakeAuthGateway();
|
||||||
|
|
@ -203,6 +205,9 @@ Dependencies testDependencies({
|
||||||
callbackScheme: 'app.halkyon.learn',
|
callbackScheme: 'app.halkyon.learn',
|
||||||
loopbackPort: 43823,
|
loopbackPort: 43823,
|
||||||
),
|
),
|
||||||
|
instanceOperators: InstanceOperatorRepository(
|
||||||
|
operatorGateway ?? FakeInstanceOperatorGateway(),
|
||||||
|
),
|
||||||
sourceCodeUrl:
|
sourceCodeUrl:
|
||||||
sourceCodeUrl ?? () async => 'https://example.org/halkyon-learn',
|
sourceCodeUrl ?? () async => 'https://example.org/halkyon-learn',
|
||||||
dictionaries: DictionaryRepository(
|
dictionaries: DictionaryRepository(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ class const _AccountSheet() extends StatelessWidget {
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
AccountTiles(deleteMessage: l10n.accountDeleteMessage),
|
AccountTiles(deleteMessage: l10n.accountDeleteMessage),
|
||||||
|
// Accounts waiting for approval, for whoever operates the
|
||||||
|
// instance alone (Halkyon Learn's ADR 0021, D34).
|
||||||
|
const InstanceOperatorTiles(),
|
||||||
// SimpleLogin keeps the account where the instance offers it
|
// SimpleLogin keeps the account where the instance offers it
|
||||||
// (ADR 0020, D21), and a password and an authenticator, signing
|
// (ADR 0020, D21), and a password and an authenticator, signing
|
||||||
// in on other devices and in the instance's other apps (ADR 0017).
|
// in on other devices and in the instance's other apps (ADR 0017).
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:halkyon_app/halkyon_app.dart';
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.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_dependencies.dart';
|
||||||
|
|
@ -62,7 +63,16 @@ class const TodoPersonApp({
|
||||||
localizationsDelegates: todoLocalizationsDelegates,
|
localizationsDelegates: todoLocalizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localeListResolutionCallback: resolveAppLocale,
|
localeListResolutionCallback: resolveAppLocale,
|
||||||
builder: (context, child) => PersonScope(person: person, child: child!),
|
// How many accounts wait, for whoever operates the instance: the
|
||||||
|
// account sheet's tiles and the badge on its button.
|
||||||
|
builder: (context, child) => PersonScope(
|
||||||
|
person: person,
|
||||||
|
child: BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
home: const HomeScreen(),
|
home: const HomeScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,12 @@ class TodoDependencies({
|
||||||
/// 0020).
|
/// 0020).
|
||||||
@override required final SimpleLoginRepository simpleLogin,
|
@override required final SimpleLoginRepository simpleLogin,
|
||||||
|
|
||||||
|
/// What the instance's operators do there (Halkyon Learn's ADR 0021,
|
||||||
|
/// D21 to D24). Todo's server is a member of the instance, which leaves
|
||||||
|
/// the operators' work to the register's service, so it shows nothing
|
||||||
|
/// until Todo's server holds the register.
|
||||||
|
@override required final InstanceOperatorRepository instanceOperators,
|
||||||
|
|
||||||
/// Keeps a file the app made where the person chooses (a recovery phrase,
|
/// Keeps a file the app made where the person chooses (a recovery phrase,
|
||||||
/// recovery codes).
|
/// recovery codes).
|
||||||
@override required final FileSaver fileSaver,
|
@override required final FileSaver fileSaver,
|
||||||
|
|
@ -115,11 +121,15 @@ class TodoDependencies({
|
||||||
callbackScheme: 'app.halkyon.todo',
|
callbackScheme: 'app.halkyon.todo',
|
||||||
loopbackPort: 43824,
|
loopbackPort: 43824,
|
||||||
);
|
);
|
||||||
|
final instanceOperators = InstanceOperatorRepository(
|
||||||
|
ServerpodInstanceOperatorGateway(client.modules.base),
|
||||||
|
);
|
||||||
return TodoDependencies(
|
return TodoDependencies(
|
||||||
serverName: instance.name,
|
serverName: instance.name,
|
||||||
auth: AuthRepository(authGateway, addresses),
|
auth: AuthRepository(authGateway, addresses),
|
||||||
passwords: passwords,
|
passwords: passwords,
|
||||||
simpleLogin: simpleLogin,
|
simpleLogin: simpleLogin,
|
||||||
|
instanceOperators: instanceOperators,
|
||||||
fileSaver: PlatformFileSaver(),
|
fileSaver: PlatformFileSaver(),
|
||||||
sourceCodeUrl: () async =>
|
sourceCodeUrl: () async =>
|
||||||
(await client.modules.base.instance.basics()).sourceCodeUrl,
|
(await client.modules.base.instance.basics()).sourceCodeUrl,
|
||||||
|
|
@ -131,6 +141,7 @@ class TodoDependencies({
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
await passwords.dispose();
|
await passwords.dispose();
|
||||||
await simpleLogin.dispose();
|
await simpleLogin.dispose();
|
||||||
|
await instanceOperators.dispose();
|
||||||
await authGateway.dispose();
|
await authGateway.dispose();
|
||||||
client.close();
|
client.close();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ class const HomeScreen({super.key}) extends StatelessWidget {
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.accountTitle,
|
tooltip: l10n.accountTitle,
|
||||||
icon: const Icon(LucideIcons.circleUserRound),
|
icon: const WaitingAccountsBadge(
|
||||||
|
child: Icon(LucideIcons.circleUserRound),
|
||||||
|
),
|
||||||
onPressed: () => showAccountSheet(context),
|
onPressed: () => showAccountSheet(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ void main() {
|
||||||
callbackScheme: 'app.halkyon.todo',
|
callbackScheme: 'app.halkyon.todo',
|
||||||
loopbackPort: 43824,
|
loopbackPort: 43824,
|
||||||
),
|
),
|
||||||
|
instanceOperators: InstanceOperatorRepository(
|
||||||
|
FakeInstanceOperatorGateway(),
|
||||||
|
),
|
||||||
fileSaver: MemoryFileSaver(),
|
fileSaver: MemoryFileSaver(),
|
||||||
sourceCodeUrl: () async =>
|
sourceCodeUrl: () async =>
|
||||||
'https://code.halkyon.app/project-halkyon/halkyon',
|
'https://code.halkyon.app/project-halkyon/halkyon',
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,33 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
||||||
a switch, the previous ones closed first; `SessionGate` shows the app of
|
a switch, the previous ones closed first; `SessionGate` shows the app of
|
||||||
whoever is signed in and asks how to start while nobody is
|
whoever is signed in and asks how to start while nobody is
|
||||||
(`StartScreen`: SimpleLogin, a password, another instance; an instance out of reach is said so, with Try again,
|
(`StartScreen`: SimpleLogin, a password, another instance; an instance out of reach is said so, with Try again,
|
||||||
and one offering no way to create an account too), at a first start as
|
and one offering no way to create an account too; where new accounts wait
|
||||||
after signing out; an anonymous account's session, left from before
|
for the operator's approval, or none are made, it says so first), at a
|
||||||
|
first start as after signing out; while an account signed in or up to
|
||||||
|
here waits for the operator (Halkyon Learn's ADR 0021, D31), `WaitScreen`
|
||||||
|
shows its handle and the four words the operator reads out for the person
|
||||||
|
to match, asks every 30 seconds and as the app comes back, deletes a new
|
||||||
|
account after asking, and once approved leads to signing in once more
|
||||||
|
(the wait kept in the keyring per instance, `SecureAccountWaitStore`, so a
|
||||||
|
restart shows it again); an anonymous account's session, left from before
|
||||||
there were none, is deleted as the app starts (ADR 0020, D25), falling
|
there were none, is deleted as the app starts (ADR 0020, D25), falling
|
||||||
back to signing out on the device; `PersonScope` opens each person's own
|
back to signing out on the device; `PersonScope` opens each person's own
|
||||||
dependencies and closes them when the person goes. `resolveAppLocale`
|
dependencies and closes them when the person goes. `resolveAppLocale`
|
||||||
picks the app's language as every Halkyon app does: the first preferred
|
picks the app's language as every Halkyon app does: the first preferred
|
||||||
language it speaks, never by country alone, British English otherwise.
|
language it speaks, never by country alone, British English otherwise.
|
||||||
|
- **The instance's operators** (`lib/src/operator/`, Halkyon Learn's ADR
|
||||||
|
0021, D21 to D24 and D34): `InstanceOperatorRepository` on
|
||||||
|
`ServerpodInstanceOperatorGateway` (the module's `instanceOperator`
|
||||||
|
endpoint, refusals as `OperatorFailed`), `OperatingCubit` (how many
|
||||||
|
accounts wait, null for anyone who doesn't operate the instance; asked as
|
||||||
|
the person's app opens, after each change, and every 5 minutes only while
|
||||||
|
they operate it), `InstanceOperatorCard` or `InstanceOperatorTiles` for an
|
||||||
|
app's settings and `WaitingAccountsBadge` for its Settings cog, both
|
||||||
|
nothing at all for anyone else, and `InstanceOperatorScreen`: the accounts
|
||||||
|
that wait, approved once their person confirmed the words read out, or
|
||||||
|
refused; who operates, named by handle and taken back (never the last);
|
||||||
|
accounts found by the start of their handle, suspended or named. Only the
|
||||||
|
register's service answers: on a member, nothing shows.
|
||||||
- **About** (`showAppAbout`): the app's name (its frame's), its version,
|
- **About** (`showAppAbout`): the app's name (its frame's), its version,
|
||||||
icon and legalese where it gives them, where the source of what the
|
icon and legalese where it gives them, where the source of what the
|
||||||
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
|
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
|
||||||
|
|
@ -174,8 +194,9 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
||||||
- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance
|
- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance
|
||||||
about itself, and to sign in as another person than the app's client holds.
|
about itself, and to sign in as another person than the app's client holds.
|
||||||
- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary
|
- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary
|
||||||
(`FakeAuthGateway`, `FakePasswordGateway`, `FakeAccountCrypto`,
|
(`FakeAuthGateway`, with waits, `fakeWaitOf`; `FakePasswordGateway`,
|
||||||
`FakeInstanceChecker`, the stores in memory), a frame
|
`FakeAccountCrypto`, `FakeInstanceChecker`, `FakeInstanceOperatorGateway`,
|
||||||
|
the stores in memory), a frame
|
||||||
(`testFrame`), `testApp`, and `TestAppDependencies`.
|
(`testFrame`), `testApp`, and `TestAppDependencies`.
|
||||||
|
|
||||||
## Using it in an app
|
## Using it in an app
|
||||||
|
|
@ -202,14 +223,19 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
||||||
(copy Learn's or Todo's `ScreenGuard.kt` and call `ScreenGuard.attach` in
|
(copy Learn's or Todo's `ScreenGuard.kt` and call `ScreenGuard.attach` in
|
||||||
`configureFlutterEngine`), or secrets show in screenshots and recents.
|
`configureFlutterEngine`), or secrets show in screenshots and recents.
|
||||||
6. **The dependencies**: the app's composition root implements
|
6. **The dependencies**: the app's composition root implements
|
||||||
`AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `passwords`,
|
`AppDependencies<P>` (`serverName`, `auth`, `passwords`, `simpleLogin`,
|
||||||
`fileSaver`, `sourceCodeUrl`, `openPerson`, `dispose`), its person's
|
`instanceOperators`, `fileSaver`, `sourceCodeUrl`, `openPerson`,
|
||||||
dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
|
`dispose`), its person's dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
|
||||||
`ServerpodPasswordGateway(client, base: client.modules.base)`,
|
`ServerpodPasswordGateway(client, base: client.modules.base)`,
|
||||||
`SodiumAccountCrypto()`, `SecureAccountKeyStore(storage.keysOf(instance))`,
|
`SodiumAccountCrypto()`, `SecureAccountKeyStore(storage.keysOf(instance))`,
|
||||||
the auth gateway and the app's `SignInAddresses`. Its `auth` is an
|
the auth gateway and the app's `SignInAddresses`. Its `auth` is an
|
||||||
`AuthRepository` on `ServerpodAuthGateway(client, base:
|
`AuthRepository` on `ServerpodAuthGateway(client, base:
|
||||||
client.modules.base)`.
|
client.modules.base, waits:
|
||||||
|
SecureAccountWaitStore(storage.waitOf(instance)))`, whose `initialize()`
|
||||||
|
it awaits before anything asks who is signed in. Its `instanceOperators`
|
||||||
|
is an `InstanceOperatorRepository` on
|
||||||
|
`ServerpodInstanceOperatorGateway(client.modules.base)`; the person's app
|
||||||
|
provides an `OperatingCubit` on it, for the card and the badge.
|
||||||
7. **The tree**:
|
7. **The tree**:
|
||||||
|
|
||||||
```dart
|
```dart
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
/// - **The account and About**: `AccountTiles` for an app's settings,
|
/// - **The account and About**: `AccountTiles` for an app's settings,
|
||||||
/// `showAppAbout` with the source of what the instance runs, and
|
/// `showAppAbout` with the source of what the instance runs, and
|
||||||
/// `resolveAppLocale`, the language every Halkyon app picks.
|
/// `resolveAppLocale`, the language every Halkyon app picks.
|
||||||
|
/// - **The instance's operators**: `InstanceOperatorCard` (or
|
||||||
|
/// `InstanceOperatorTiles`) and `WaitingAccountsBadge` for whoever
|
||||||
|
/// operates the instance, on `OperatingCubit`, leading to
|
||||||
|
/// `InstanceOperatorScreen`, where accounts are approved, refused and
|
||||||
|
/// suspended and operators named (Halkyon Learn's ADR 0021, D21 to D24).
|
||||||
/// - **Signing in**: with SimpleLogin (ADR 0020: `SimpleLoginRepository`,
|
/// - **Signing in**: with SimpleLogin (ADR 0020: `SimpleLoginRepository`,
|
||||||
/// its sheet and `SimpleLoginSection`), with a password and TOTP (ADR
|
/// its sheet and `SimpleLoginSection`), with a password and TOTP (ADR
|
||||||
/// 0017: `PasswordRepository`, its sheets and `AccountSecuritySection`),
|
/// 0017: `PasswordRepository`, its sheets and `AccountSecuritySection`),
|
||||||
|
|
@ -74,6 +79,14 @@ export 'src/instance/widget/instance_location.dart';
|
||||||
export 'src/instance/widget/instance_location_words.dart';
|
export 'src/instance/widget/instance_location_words.dart';
|
||||||
export 'src/instance/widget/instance_step.dart';
|
export 'src/instance/widget/instance_step.dart';
|
||||||
export 'src/instance/widget/instance_tile.dart';
|
export 'src/instance/widget/instance_tile.dart';
|
||||||
|
export 'src/operator/bloc/instance_operator_cubit.dart';
|
||||||
|
export 'src/operator/bloc/operating_cubit.dart';
|
||||||
|
export 'src/operator/data/instance_operator_gateway.dart';
|
||||||
|
export 'src/operator/data/instance_operator_repository.dart';
|
||||||
|
export 'src/operator/data/serverpod_instance_operator_gateway.dart';
|
||||||
|
export 'src/operator/model/operated_account.dart';
|
||||||
|
export 'src/operator/widget/instance_operator_card.dart';
|
||||||
|
export 'src/operator/widget/instance_operator_screen.dart';
|
||||||
export 'src/password/bloc/account_recovery_cubit.dart';
|
export 'src/password/bloc/account_recovery_cubit.dart';
|
||||||
export 'src/password/bloc/account_security_cubit.dart';
|
export 'src/password/bloc/account_security_cubit.dart';
|
||||||
export 'src/password/bloc/account_setup_cubit.dart';
|
export 'src/password/bloc/account_setup_cubit.dart';
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@
|
||||||
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||||
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
|
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
|
||||||
"signUpsClosed": "Diese Instanz nimmt keine neuen Konten an.",
|
"signUpsClosed": "Diese Instanz nimmt keine neuen Konten an.",
|
||||||
"signUpsTooManyWaiting": "Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.",
|
"signUpsTooManyWaiting": "Es warten schon zu viele neue Konten auf ihre Freigabe. Versuch es später noch einmal.",
|
||||||
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||||
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
||||||
"passwordMinLength": "Mindestens {count} Zeichen",
|
"passwordMinLength": "Mindestens {count} Zeichen",
|
||||||
|
|
@ -309,5 +309,41 @@
|
||||||
"simpleLoginSectionTitle": "SimpleLogin",
|
"simpleLoginSectionTitle": "SimpleLogin",
|
||||||
"simpleLoginSignsYouIn": "Meldet dich an",
|
"simpleLoginSignsYouIn": "Meldet dich an",
|
||||||
"simpleLoginAddHint": "Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.",
|
"simpleLoginAddHint": "Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.",
|
||||||
"simpleLoginAdded": "SimpleLogin hinzugefügt."
|
"simpleLoginAdded": "SimpleLogin hinzugefügt.",
|
||||||
|
"operatorTitle": "Diese Instanz",
|
||||||
|
"operatorAccounts": "Konten und Admins",
|
||||||
|
"operatorWaitingCount": "{count, plural, =0{Niemand wartet auf Freigabe} =1{1 Konto wartet auf Freigabe} other{{count} Konten warten auf Freigabe}}",
|
||||||
|
"operatorWaitingTitle": "Wartend",
|
||||||
|
"operatorNobodyWaits": "Niemand wartet.",
|
||||||
|
"operatorNoHandle": "Noch kein Handle",
|
||||||
|
"operatorNewSince": "Neu, wartet seit {date}",
|
||||||
|
"operatorSuspendedSince": "Gesperrt seit {date}",
|
||||||
|
"operatorApproveNote": "Lies diese vier Wörter vor, und gib das Konto erst frei, wenn dir bestätigt wird, dass genau diese auf dem Bildschirm stehen.",
|
||||||
|
"operatorApprove": "Freigeben",
|
||||||
|
"operatorRefuse": "Ablehnen",
|
||||||
|
"operatorRefuseTitle": "Dieses Konto ablehnen?",
|
||||||
|
"operatorRefuseMessage": "Es wird mit allem gelöscht, was darin ist. Eine neue Anmeldung bleibt möglich.",
|
||||||
|
"operatorOperatorsTitle": "Admins",
|
||||||
|
"operatorTakeBack": "Entziehen",
|
||||||
|
"operatorTakeBackTitle": "Die Rolle entziehen?",
|
||||||
|
"operatorTakeBackMessage": "Das Konto bleibt, kann hier aber keine Konten mehr freigeben, ablehnen oder sperren.",
|
||||||
|
"operatorAppoint": "Admin ernennen",
|
||||||
|
"operatorAppointMessage": "Admins geben hier Konten frei, lehnen sie ab, sperren sie und ernennen Admins, so wie du.",
|
||||||
|
"operatorAppointConfirm": "Ernennen",
|
||||||
|
"operatorHandle": "Handle",
|
||||||
|
"operatorFindTitle": "Ein Konto finden",
|
||||||
|
"operatorFoundNone": "Kein Handle beginnt so.",
|
||||||
|
"operatorMadeOn": "Seit {date}",
|
||||||
|
"operatorIsOperator": "Admin",
|
||||||
|
"operatorIsWaiting": "Wartend",
|
||||||
|
"operatorIsSuspended": "Gesperrt",
|
||||||
|
"operatorSuspend": "Sperren",
|
||||||
|
"operatorSuspendTitle": "Dieses Konto sperren?",
|
||||||
|
"operatorSuspendMessage": "Es wird überall abgemeldet und kann sich erst wieder anmelden, wenn du es freigibst. Nichts darin wird gelöscht.",
|
||||||
|
"operatorMakeOperator": "Zum Admin ernennen",
|
||||||
|
"operatorNotFound": "Dieses Konto gibt es nicht mehr, oder es wartet nicht.",
|
||||||
|
"operatorIsOperatorProblem": "Admins können nicht gesperrt werden. Entzieh zuerst die Rolle.",
|
||||||
|
"operatorLastOperator": "Die Instanz braucht mindestens einen Admin.",
|
||||||
|
"operatorNotAllowed": "Das können nur die Admins der Instanz.",
|
||||||
|
"operatorFailed": "Das hat nicht geklappt. Versuch es noch einmal."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@
|
||||||
"@signUpsClosed": {
|
"@signUpsClosed": {
|
||||||
"description": "A sign-up refused: the operator takes no new accounts (D19)."
|
"description": "A sign-up refused: the operator takes no new accounts (D19)."
|
||||||
},
|
},
|
||||||
"signUpsTooManyWaiting": "As many new accounts wait for approval as may. Try again later.",
|
"signUpsTooManyWaiting": "Too many new accounts already wait for approval. Try again later.",
|
||||||
"@signUpsTooManyWaiting": {
|
"@signUpsTooManyWaiting": {
|
||||||
"description": "A sign-up refused: ten new accounts wait for the operator's approval already (D19)."
|
"description": "A sign-up refused: ten new accounts wait for the operator's approval already (D19)."
|
||||||
},
|
},
|
||||||
|
|
@ -742,5 +742,169 @@
|
||||||
"simpleLoginSectionTitle": "SimpleLogin",
|
"simpleLoginSectionTitle": "SimpleLogin",
|
||||||
"simpleLoginSignsYouIn": "Signs you in",
|
"simpleLoginSignsYouIn": "Signs you in",
|
||||||
"simpleLoginAddHint": "Sign in with SimpleLogin, or with your Proton account.",
|
"simpleLoginAddHint": "Sign in with SimpleLogin, or with your Proton account.",
|
||||||
"simpleLoginAdded": "SimpleLogin added."
|
"simpleLoginAdded": "SimpleLogin added.",
|
||||||
|
"operatorTitle": "This instance",
|
||||||
|
"@operatorTitle": {
|
||||||
|
"description": "The card and screen for whoever operates the instance (ADR 0021, D34)."
|
||||||
|
},
|
||||||
|
"operatorAccounts": "Accounts and operators",
|
||||||
|
"@operatorAccounts": {
|
||||||
|
"description": "The tile leading to the operator's screen."
|
||||||
|
},
|
||||||
|
"operatorWaitingCount": "{count, plural, =0{Nobody waits for approval} =1{1 account waits for approval} other{{count} accounts wait for approval}}",
|
||||||
|
"@operatorWaitingCount": {
|
||||||
|
"description": "How many accounts wait for the operator; also the badge's label.",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operatorWaitingTitle": "Waiting",
|
||||||
|
"@operatorWaitingTitle": {
|
||||||
|
"description": "The card of accounts that wait for approval, new or suspended."
|
||||||
|
},
|
||||||
|
"operatorNobodyWaits": "Nobody waits.",
|
||||||
|
"@operatorNobodyWaits": {
|
||||||
|
"description": "The waiting card, empty."
|
||||||
|
},
|
||||||
|
"operatorNoHandle": "No handle yet",
|
||||||
|
"@operatorNoHandle": {
|
||||||
|
"description": "An account without a handle, where its handle would be."
|
||||||
|
},
|
||||||
|
"operatorNewSince": "New, waiting since {date}",
|
||||||
|
"@operatorNewSince": {
|
||||||
|
"description": "A new account that waits, and since when.",
|
||||||
|
"placeholders": {
|
||||||
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operatorSuspendedSince": "Suspended since {date}",
|
||||||
|
"@operatorSuspendedSince": {
|
||||||
|
"description": "A suspended account, and since when.",
|
||||||
|
"placeholders": {
|
||||||
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operatorApproveNote": "Read them these four words, and approve only once they confirm these are on their screen.",
|
||||||
|
"@operatorApproveNote": {
|
||||||
|
"description": "Before approving: the words prove it is their account that waits (D33)."
|
||||||
|
},
|
||||||
|
"operatorApprove": "Approve",
|
||||||
|
"@operatorApprove": {
|
||||||
|
"description": "Approves a waiting account."
|
||||||
|
},
|
||||||
|
"operatorRefuse": "Refuse",
|
||||||
|
"@operatorRefuse": {
|
||||||
|
"description": "Refuses a new account: deleted."
|
||||||
|
},
|
||||||
|
"operatorRefuseTitle": "Refuse this account?",
|
||||||
|
"@operatorRefuseTitle": {
|
||||||
|
"description": "The question before refusing."
|
||||||
|
},
|
||||||
|
"operatorRefuseMessage": "It is deleted with everything it has. They can sign up again.",
|
||||||
|
"@operatorRefuseMessage": {
|
||||||
|
"description": "What refusing does."
|
||||||
|
},
|
||||||
|
"operatorOperatorsTitle": "Operators",
|
||||||
|
"@operatorOperatorsTitle": {
|
||||||
|
"description": "The card of who operates the instance."
|
||||||
|
},
|
||||||
|
"operatorTakeBack": "Take back",
|
||||||
|
"@operatorTakeBack": {
|
||||||
|
"description": "Takes back the operator's role."
|
||||||
|
},
|
||||||
|
"operatorTakeBackTitle": "Take back the role?",
|
||||||
|
"@operatorTakeBackTitle": {
|
||||||
|
"description": "The question before taking the role back."
|
||||||
|
},
|
||||||
|
"operatorTakeBackMessage": "They keep their account, but no longer approve, refuse or suspend accounts here.",
|
||||||
|
"@operatorTakeBackMessage": {
|
||||||
|
"description": "What taking the role back does."
|
||||||
|
},
|
||||||
|
"operatorAppoint": "Name an operator",
|
||||||
|
"@operatorAppoint": {
|
||||||
|
"description": "Names an account an operator, by its handle."
|
||||||
|
},
|
||||||
|
"operatorAppointMessage": "They approve, refuse and suspend accounts here, and name operators, as you do.",
|
||||||
|
"@operatorAppointMessage": {
|
||||||
|
"description": "What an operator may do."
|
||||||
|
},
|
||||||
|
"operatorAppointConfirm": "Name",
|
||||||
|
"@operatorAppointConfirm": {
|
||||||
|
"description": "Confirms naming an operator."
|
||||||
|
},
|
||||||
|
"operatorHandle": "Handle",
|
||||||
|
"@operatorHandle": {
|
||||||
|
"description": "The field for an account's handle."
|
||||||
|
},
|
||||||
|
"operatorFindTitle": "Find an account",
|
||||||
|
"@operatorFindTitle": {
|
||||||
|
"description": "The card searching accounts by the start of their handle."
|
||||||
|
},
|
||||||
|
"operatorFoundNone": "No account's handle starts like this.",
|
||||||
|
"@operatorFoundNone": {
|
||||||
|
"description": "A search that found nothing."
|
||||||
|
},
|
||||||
|
"operatorMadeOn": "Since {date}",
|
||||||
|
"@operatorMadeOn": {
|
||||||
|
"description": "When an account was made.",
|
||||||
|
"placeholders": {
|
||||||
|
"date": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"operatorIsOperator": "Operator",
|
||||||
|
"@operatorIsOperator": {
|
||||||
|
"description": "A tag: the account operates the instance."
|
||||||
|
},
|
||||||
|
"operatorIsWaiting": "Waiting",
|
||||||
|
"@operatorIsWaiting": {
|
||||||
|
"description": "A tag: the account waits for approval."
|
||||||
|
},
|
||||||
|
"operatorIsSuspended": "Suspended",
|
||||||
|
"@operatorIsSuspended": {
|
||||||
|
"description": "A tag: the account is suspended."
|
||||||
|
},
|
||||||
|
"operatorSuspend": "Suspend",
|
||||||
|
"@operatorSuspend": {
|
||||||
|
"description": "Suspends an account."
|
||||||
|
},
|
||||||
|
"operatorSuspendTitle": "Suspend this account?",
|
||||||
|
"@operatorSuspendTitle": {
|
||||||
|
"description": "The question before suspending."
|
||||||
|
},
|
||||||
|
"operatorSuspendMessage": "They are signed out everywhere and can't sign in until you approve them again. Nothing they have is deleted.",
|
||||||
|
"@operatorSuspendMessage": {
|
||||||
|
"description": "What suspending does."
|
||||||
|
},
|
||||||
|
"operatorMakeOperator": "Name as operator",
|
||||||
|
"@operatorMakeOperator": {
|
||||||
|
"description": "Names a found account an operator."
|
||||||
|
},
|
||||||
|
"operatorNotFound": "That account isn't there any more, or doesn't wait.",
|
||||||
|
"@operatorNotFound": {
|
||||||
|
"description": "Refused: no such account, or it doesn't wait."
|
||||||
|
},
|
||||||
|
"operatorIsOperatorProblem": "An operator can't be suspended. Take back their role first.",
|
||||||
|
"@operatorIsOperatorProblem": {
|
||||||
|
"description": "Refused: suspending an operator."
|
||||||
|
},
|
||||||
|
"operatorLastOperator": "The instance needs at least one operator.",
|
||||||
|
"@operatorLastOperator": {
|
||||||
|
"description": "Refused: taking back the last operator's role."
|
||||||
|
},
|
||||||
|
"operatorNotAllowed": "Only the instance's operators can do this.",
|
||||||
|
"@operatorNotAllowed": {
|
||||||
|
"description": "Refused: the person doesn't operate the instance."
|
||||||
|
},
|
||||||
|
"operatorFailed": "That didn't work. Try again.",
|
||||||
|
"@operatorFailed": {
|
||||||
|
"description": "Any other failure."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1402,7 +1402,7 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// A sign-up refused: ten new accounts wait for the operator's approval already (D19).
|
/// A sign-up refused: ten new accounts wait for the operator's approval already (D19).
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'As many new accounts wait for approval as may. Try again later.'**
|
/// **'Too many new accounts already wait for approval. Try again later.'**
|
||||||
String get signUpsTooManyWaiting;
|
String get signUpsTooManyWaiting;
|
||||||
|
|
||||||
/// No description provided for @passwordUnsafeCosts.
|
/// No description provided for @passwordUnsafeCosts.
|
||||||
|
|
@ -1962,6 +1962,222 @@ abstract class HalkyonAppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'SimpleLogin added.'**
|
/// **'SimpleLogin added.'**
|
||||||
String get simpleLoginAdded;
|
String get simpleLoginAdded;
|
||||||
|
|
||||||
|
/// The card and screen for whoever operates the instance (ADR 0021, D34).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This instance'**
|
||||||
|
String get operatorTitle;
|
||||||
|
|
||||||
|
/// The tile leading to the operator's screen.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Accounts and operators'**
|
||||||
|
String get operatorAccounts;
|
||||||
|
|
||||||
|
/// How many accounts wait for the operator; also the badge's label.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =0{Nobody waits for approval} =1{1 account waits for approval} other{{count} accounts wait for approval}}'**
|
||||||
|
String operatorWaitingCount(int count);
|
||||||
|
|
||||||
|
/// The card of accounts that wait for approval, new or suspended.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Waiting'**
|
||||||
|
String get operatorWaitingTitle;
|
||||||
|
|
||||||
|
/// The waiting card, empty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Nobody waits.'**
|
||||||
|
String get operatorNobodyWaits;
|
||||||
|
|
||||||
|
/// An account without a handle, where its handle would be.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No handle yet'**
|
||||||
|
String get operatorNoHandle;
|
||||||
|
|
||||||
|
/// A new account that waits, and since when.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New, waiting since {date}'**
|
||||||
|
String operatorNewSince(String date);
|
||||||
|
|
||||||
|
/// A suspended account, and since when.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Suspended since {date}'**
|
||||||
|
String operatorSuspendedSince(String date);
|
||||||
|
|
||||||
|
/// Before approving: the words prove it is their account that waits (D33).
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Read them these four words, and approve only once they confirm these are on their screen.'**
|
||||||
|
String get operatorApproveNote;
|
||||||
|
|
||||||
|
/// Approves a waiting account.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Approve'**
|
||||||
|
String get operatorApprove;
|
||||||
|
|
||||||
|
/// Refuses a new account: deleted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refuse'**
|
||||||
|
String get operatorRefuse;
|
||||||
|
|
||||||
|
/// The question before refusing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Refuse this account?'**
|
||||||
|
String get operatorRefuseTitle;
|
||||||
|
|
||||||
|
/// What refusing does.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'It is deleted with everything it has. They can sign up again.'**
|
||||||
|
String get operatorRefuseMessage;
|
||||||
|
|
||||||
|
/// The card of who operates the instance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Operators'**
|
||||||
|
String get operatorOperatorsTitle;
|
||||||
|
|
||||||
|
/// Takes back the operator's role.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Take back'**
|
||||||
|
String get operatorTakeBack;
|
||||||
|
|
||||||
|
/// The question before taking the role back.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Take back the role?'**
|
||||||
|
String get operatorTakeBackTitle;
|
||||||
|
|
||||||
|
/// What taking the role back does.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'They keep their account, but no longer approve, refuse or suspend accounts here.'**
|
||||||
|
String get operatorTakeBackMessage;
|
||||||
|
|
||||||
|
/// Names an account an operator, by its handle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Name an operator'**
|
||||||
|
String get operatorAppoint;
|
||||||
|
|
||||||
|
/// What an operator may do.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'They approve, refuse and suspend accounts here, and name operators, as you do.'**
|
||||||
|
String get operatorAppointMessage;
|
||||||
|
|
||||||
|
/// Confirms naming an operator.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Name'**
|
||||||
|
String get operatorAppointConfirm;
|
||||||
|
|
||||||
|
/// The field for an account's handle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Handle'**
|
||||||
|
String get operatorHandle;
|
||||||
|
|
||||||
|
/// The card searching accounts by the start of their handle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Find an account'**
|
||||||
|
String get operatorFindTitle;
|
||||||
|
|
||||||
|
/// A search that found nothing.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No account\'s handle starts like this.'**
|
||||||
|
String get operatorFoundNone;
|
||||||
|
|
||||||
|
/// When an account was made.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Since {date}'**
|
||||||
|
String operatorMadeOn(String date);
|
||||||
|
|
||||||
|
/// A tag: the account operates the instance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Operator'**
|
||||||
|
String get operatorIsOperator;
|
||||||
|
|
||||||
|
/// A tag: the account waits for approval.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Waiting'**
|
||||||
|
String get operatorIsWaiting;
|
||||||
|
|
||||||
|
/// A tag: the account is suspended.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Suspended'**
|
||||||
|
String get operatorIsSuspended;
|
||||||
|
|
||||||
|
/// Suspends an account.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Suspend'**
|
||||||
|
String get operatorSuspend;
|
||||||
|
|
||||||
|
/// The question before suspending.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Suspend this account?'**
|
||||||
|
String get operatorSuspendTitle;
|
||||||
|
|
||||||
|
/// What suspending does.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'They are signed out everywhere and can\'t sign in until you approve them again. Nothing they have is deleted.'**
|
||||||
|
String get operatorSuspendMessage;
|
||||||
|
|
||||||
|
/// Names a found account an operator.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Name as operator'**
|
||||||
|
String get operatorMakeOperator;
|
||||||
|
|
||||||
|
/// Refused: no such account, or it doesn't wait.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'That account isn\'t there any more, or doesn\'t wait.'**
|
||||||
|
String get operatorNotFound;
|
||||||
|
|
||||||
|
/// Refused: suspending an operator.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'An operator can\'t be suspended. Take back their role first.'**
|
||||||
|
String get operatorIsOperatorProblem;
|
||||||
|
|
||||||
|
/// Refused: taking back the last operator's role.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The instance needs at least one operator.'**
|
||||||
|
String get operatorLastOperator;
|
||||||
|
|
||||||
|
/// Refused: the person doesn't operate the instance.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Only the instance\'s operators can do this.'**
|
||||||
|
String get operatorNotAllowed;
|
||||||
|
|
||||||
|
/// Any other failure.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'That didn\'t work. Try again.'**
|
||||||
|
String get operatorFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HalkyonAppLocalizationsDelegate
|
class _HalkyonAppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -821,7 +821,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get signUpsTooManyWaiting =>
|
String get signUpsTooManyWaiting =>
|
||||||
'Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.';
|
'Es warten schon zu viele neue Konten auf ihre Freigabe. Versuch es später noch einmal.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordUnsafeCosts =>
|
String get passwordUnsafeCosts =>
|
||||||
|
|
@ -1172,4 +1172,136 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get simpleLoginAdded => 'SimpleLogin hinzugefügt.';
|
String get simpleLoginAdded => 'SimpleLogin hinzugefügt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorTitle => 'Diese Instanz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorAccounts => 'Konten und Admins';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String operatorWaitingCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Konten warten auf Freigabe',
|
||||||
|
one: '1 Konto wartet auf Freigabe',
|
||||||
|
zero: 'Niemand wartet auf Freigabe',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorWaitingTitle => 'Wartend';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorNobodyWaits => 'Niemand wartet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorNoHandle => 'Noch kein Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String operatorNewSince(String date) {
|
||||||
|
return 'Neu, wartet seit $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String operatorSuspendedSince(String date) {
|
||||||
|
return 'Gesperrt seit $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorApproveNote =>
|
||||||
|
'Lies diese vier Wörter vor, und gib das Konto erst frei, wenn dir bestätigt wird, dass genau diese auf dem Bildschirm stehen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorApprove => 'Freigeben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorRefuse => 'Ablehnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorRefuseTitle => 'Dieses Konto ablehnen?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorRefuseMessage =>
|
||||||
|
'Es wird mit allem gelöscht, was darin ist. Eine neue Anmeldung bleibt möglich.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorOperatorsTitle => 'Admins';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorTakeBack => 'Entziehen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorTakeBackTitle => 'Die Rolle entziehen?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorTakeBackMessage =>
|
||||||
|
'Das Konto bleibt, kann hier aber keine Konten mehr freigeben, ablehnen oder sperren.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorAppoint => 'Admin ernennen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorAppointMessage =>
|
||||||
|
'Admins geben hier Konten frei, lehnen sie ab, sperren sie und ernennen Admins, so wie du.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorAppointConfirm => 'Ernennen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorHandle => 'Handle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorFindTitle => 'Ein Konto finden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorFoundNone => 'Kein Handle beginnt so.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String operatorMadeOn(String date) {
|
||||||
|
return 'Seit $date';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorIsOperator => 'Admin';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorIsWaiting => 'Wartend';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorIsSuspended => 'Gesperrt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorSuspend => 'Sperren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorSuspendTitle => 'Dieses Konto sperren?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorSuspendMessage =>
|
||||||
|
'Es wird überall abgemeldet und kann sich erst wieder anmelden, wenn du es freigibst. Nichts darin wird gelöscht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorMakeOperator => 'Zum Admin ernennen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorNotFound =>
|
||||||
|
'Dieses Konto gibt es nicht mehr, oder es wartet nicht.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorIsOperatorProblem =>
|
||||||
|
'Admins können nicht gesperrt werden. Entzieh zuerst die Rolle.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorLastOperator =>
|
||||||
|
'Die Instanz braucht mindestens einen Admin.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorNotAllowed => 'Das können nur die Admins der Instanz.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get operatorFailed =>
|
||||||
|
'Das hat nicht geklappt. Versuch es noch einmal.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue