feat(app): what's new after an update, drafted by the release tool (ADR 0030, D61)

halkyon_app's WhatsNew reads what each release changed from the app's
assets/whats_new/ (<version>.<language>.txt, English and German, a change
a line): after an update a sheet lists every release since the one the
device showed last, newest first, once; Settings shows the running
version's again, above About. Nothing on a fresh install.

tool/release.dart drafts the missing texts from the commits that count
after "Go on?" (German marked TRANSLATE:), opens them in the editor until
they fit (no mark left, F-Droid's 500 characters) and commits them with
the version. The English is the Forgejo notes' What's new, each language
F-Droid's changelog of the build, written at publishing and copied in by
tool/fdroid_repo.sh. Learn and Train bundle their texts, Learn's 0.22.0
and Train's 0.1.0 written to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-31 19:36:20 +01:00
commit 7727317c68
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
34 changed files with 1272 additions and 76 deletions

View file

@ -10,7 +10,7 @@ Halkyon's apps and the packages they share, in one repository and one pub worksp
- **`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 (the floating bar and rail, `FoldingTabs`' tab row folding into the title), 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, 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), the operator's assistants forwarded on halkyon_llm with their grants, each person's budget, the efforts they allow and counted figures, and the instance's privacy page made from its settings (step 2), 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`), the wait screen of an account waiting for approval and the operator's card, badge and screen (ADR 0021), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), 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), the assistants of an app's settings (`AssistantsCard`: granted, by provider, agreed to, what is left of the person's budget, used, and the person's own keys) and the operator's grants from that screen (`AssistantGrantsScreen`: several assistants to several people at once, each with one budget, D57 to D63), with the repositories asking them through the module, at an effort the operator allows, or a person's own key directly (step 2) and what an app's warning before a private studyset goes names (`providerTermsOf`, `OwnAssistant.onThisMachine`, step 3), About with the instance's source link (`showAppAbout`), what's new after an update (`WhatsNew`, `WhatsNewOnUpdate`, `WhatsNewTile`, from the app's `assets/whats_new/`, Train's ADR 0030, D61), 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_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, row IDs keyed by the account (`keyedRowId`). 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_llm` (pure Dart, Halkyon Learn's ADR 0021, D15 and D46): language models' APIs streamed over plain HTTP, no SDKs: Anthropic's Messages API, OpenAI's chat completions (each known host's fields, D45) and its Responses API, behind one interface (`LlmClient`), with `FakeLlmClient` for tests; tested on replies written from the providers' documentation and on those `tool/record_llm.dart` records with a key (D36). The module forwards the operator's assistants with it, and the apps call a person's own provider. BSD-3-Clause as `halkyon_oidc`, `publish_to: none`.
@ -82,7 +82,7 @@ Checklist after doing changes, in this order:
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
- **Melos** (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (`dart run melos …`, its version in the lock), its scripts under `melos:` in the root `pubspec.yaml`: `analyze`; `test:dart` (the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port); `test:flutter` (`flutter test -x requires-server`, one package at a time); `generate` (Serverpod's code generation in every package with a `config/generator.yaml`, the CLI on the PATH); `check` (`tool/check.dart`). Every script takes Melos' filters: `dart run melos run test:flutter --scope=halkyon_app`, or `--diff=origin/main --include-dependents` for what changed and what depends on it; `dart run melos list` lists the packages. The root overrides `cli_util` to 0.5.2 for it: Learn's `flutter_launcher_icons` (0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5.
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI, through `tool/with_signing_keys.sh learn …` (Bash glue): the keystores are kept in Proton Pass alone (vault *Infra · Signing*, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory (`$XDG_RUNTIME_DIR`), hands their paths to the release tool and removes them however the run ends (`test/release/signing_keys_test.dart` holds it to every app released). Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI, through `tool/with_signing_keys.sh learn …` (Bash glue): the keystores are kept in Proton Pass alone (vault *Infra · Signing*, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory (`$XDG_RUNTIME_DIR`), hands their paths to the release tool and removes them however the run ends (`test/release/signing_keys_test.dart` holds it to every app released). Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. Each release's *What's new* (Train's ADR 0030, D61), one file a language in the app's `assets/whats_new/` (`<version>.<language>.txt`, English and German), is drafted by it from the commits after *Go on?* and edited in `$VISUAL`/`$EDITOR` until it fits (no `TRANSLATE:` left, F-Droid's 500 characters), committed with the version, and is the app's sheet after the update, the Forgejo notes and F-Droid's changelog. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
## CI and deploys

View file

@ -67,7 +67,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **A green `main` deploys learn.halkyon.eu** through `deploy/redeploy.sh`, the only command `deploy`'s keys may run (pinned in its `authorized_keys`: the runner's from that server itself only, the developer's for deploys by hand). It deploys the commit CI checked while it is still the tip, as a fast-forward, only if every new commit is signed by a key in `deploy/allowed-signers` (the deployed copy decides), after dumping the database (the last 3 dumps stay in `~deploy/backups`: the way back from a deploy, not backups), applies the migrations in a run of their own before switching (`--role=maintenance --apply-migrations`: a failing migration stops the deploy while the old server keeps serving, so migrations must keep the old server working until the switch), and fails unless the new server answers. A failed deploy leaves the checkout at the deployed commit; run the workflow again to retry. By hand: `ssh sahaquiel-deploy <commit>`, the commit alone, as CI passes it (the server's checkout keeps its folder `halkyon-learn`, so the command the keys are pinned to stays). Never `docker compose down -v` there.- **From the single repository** (ADR 0015): learn.halkyon.eu deploys this repository's `main`, whichever app or shared package a push changed. Its images are built from the repository's root with only what they need (Learn's server and the module it builds on, with the module's client, which the module's tests depend on; the curator), each Dockerfile narrowing the workspace to those packages (`tool/workspace_only.sh`). The first deploy after the move starts the server once more than it needs to, as the deployed copy of `redeploy.sh` looks for migrations at their old path.
- **Sidecars** (`deploy/sidecars/README.md`, decided 2026-10-14): services an instance may run beside itself, each a compose file of its own beside `deploy/compose.yaml` (`compose.jellyfin.yaml`, `compose.languagetool.yaml`; later Fedify), switched on in `deploy/.env` (`COMPOSE_FILE=compose.yaml:compose.jellyfin.yaml`), so `redeploy.sh` deploys them with the instance. The instance's compose has two networks: `edge` (`halkyon-learn-edge`, 172.30.80.0/24 and the unique local fd4a:6c6b:7e80::/64, so Docker hands Caddy IPv6 visitors' own addresses), Caddy's at fixed addresses (172.30.80.2, fd4a:6c6b:7e80::2; the server's `TRUSTED_PROXIES`), shared with the server, and the internal default one, where the server reaches Postgres; a sidecar joins `edge` alone and publishes no port (the CI runner may reach `127.0.0.1`, never Docker's 172.16.0.0/12 or fd00::/8). A sidecar reached from outside mounts its site from `deploy/sidecars/` into Caddy's `/etc/caddy/sidecars/`, which the Caddyfile imports (none without), and `redeploy.sh` recreates Caddy when a site changes. Each runs as a user ID of its own (Jellyfin 61096, LanguageTool 61081), never one a person, another container or the runner uses; its files on the server are git-ignored (`deploy/jellyfin/`, `deploy/languagetool/`). The Jellyfin sidecar sets the server's `JELLYFIN_URL`, so the instance suggests it; the LanguageTool sidecar (ADR 0024) sets `LANGUAGETOOL_URL` and has no site, as LanguageTool has no sign-in: only the server reaches it, and logs nothing (`logging: none`).
- **Backups** (`deploy/backups.md`): every hour, `deploy/backup.sh` (a systemd timer, as `deploy`) dumps the database, which holds people's files too, then bundles each published studyset's repository (`git bundle`, from the server's `repositories` volume), and restic stores both in a Hetzner Storage Box (24 hourly, 7 daily, 4 weekly, 3 monthly); the box's own snapshots protect it from a taken-over server. A restore takes the instance back up to an hour, and people who started since lose what their device held: their app no longer finds its session and asks how to start, and an account made since is gone with it. So a change that makes devices lose their session, or keeps deleted people's data longer, changes what a restore costs; say so in `deploy/backups.md`.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 (`v0.18.0`) was the last from `tool/release.sh`, the Bash script it replaced, removed since. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The snap goes to the Snap Store's edge channel last (`storeChannel` in `apps/learn/release.dart`; the release refuses to start unless `snapcraft whoami` shows a login). A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the release tool releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to the halkyon.app server (`ssh ramiel-fdroid`) as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
- **Releases** are the project's, not an instance's: the release, the Snap Store listing and the F-Droid listing point to the project's page on Halkyon's website, halkyon.app/learn (its own repository, `project-halkyon/website`; learn.halkyon.app redirects there); the builds name learn.halkyon.eu, the project's instance (`SERVER_URL`), which a new install offers first (see *Instances*). They come from a developer's laptop, never from CI: `dart run tool/release.dart learn` on `main` as pushed and green in CI (the apps' shared release tool, ADR 0015, configured in `apps/learn/release.dart`; `--dry-run` shows the version, the commits that count, every step and the notes without changing anything). Build 21 (`v0.18.0`) was the last from `tool/release.sh`, the Bash script it replaced, removed since. Versions follow semantic versioning, worked out from the commits touching Learn since its last release (`apps/learn/`, the shared packages it uses, the root pubspec and lock, its release files, and its packages' folders at the root from before the move; `feat` raises the minor number, `fix`/`perf` the patch number, breaking ones the major from 1.0 on; other types release nothing), so **commit messages keep their type prefix**. It writes the version and a raised build number into the app's pubspec in a signed release commit (`chore(release): learn-vX.Y.Z`) with a signed tag (`learn-vX.Y.Z`, by a key in `deploy/allowed-signers`; Learn's earlier `vX.Y.Z` tags count as its history), builds one universal APK from a checkout of the tag, signed with the app's key, and the snap (`snap/snapcraft.yaml`, the committed HEAD, built in LXD), both with `SERVER_URL=https://learn.halkyon.eu/`; signs `SHA256SUMS` with GPG; drafts what's new in the release for the developer to edit, committed with the version in `assets/whats_new/` (Train's ADR 0030, D61: the sheet after the update, Settings' entry above About, the notes and F-Droid's changelog); pushes only once both builds passed their checks; and publishes on code.halkyon.app through Forgejo's API and in the F-Droid repository. The snap goes to the Snap Store's edge channel last (`storeChannel` in `apps/learn/release.dart`; the release refuses to start unless `snapcraft whoami` shows a login). A run that stopped after its release commit and tag carries on with them. The key's password and the Forgejo token are asked for at each run and never stored. The app's key is a PKCS #12 keystore outside the repository (also in the maintainer's password manager); there is no Google Play, so it is the app's identity: lost, installed apps can't be updated. Its certificate's SHA-256 is in `tool/release/android-certificate.sha256`, and the release tool releases no APK signed otherwise; other release builds fall back to the debug key (`android/app/build.gradle.kts`), so `flutter run --release` works anywhere. The APK also goes into the project's own F-Droid repository, https://halkyon.app/fdroid/repo, through `tool/fdroid_repo.sh` (fdroidserver v2.4.5): its settings and the app's listing are in `tool/release/fdroid/` (the anti-feature NonFreeNet declared with its reasons and F-Droid's own icon; not TetheredNet, as the app chooses its instance); its index is signed with a key of its own (a PKCS #12 keystore beside the app's, password asked for, certificate pinned in `tool/release/fdroid-index-certificate.sha256`, since a new key makes the F-Droid app refuse the repository); and it is uploaded to the halkyon.app server (`ssh ramiel-fdroid`) as the user `fdroid`, whose key can only write into the folder Caddy serves (rrsync), the APKs first and the indexes last.
## Rules the code relies on

View file

@ -0,0 +1,4 @@
- Wähle, wie viel ein Assistent nachdenkt, und finde die Assistenten nach Anbieter.
- Ein Budget für alle deine Assistenten statt eines für jeden.
- Betreiber vergeben Assistenten an mehrere Personen auf einmal, jede mit einem Budget.
- Die Rechtschreibprüfung kommt von selbst zurück, eine Minute nachdem LanguageTool nicht antwortete.

View file

@ -0,0 +1,4 @@
- Choose how much an assistant thinks, and find the assistants by provider.
- One budget for all your assistants, instead of one each.
- Operators grant assistants to several people at once, each with one budget.
- Spell checking comes back by itself a minute after LanguageTool didn't answer.

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/src/app/app_icon_license.dart';
@ -42,15 +43,24 @@ Future<void> main() async {
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
InstanceGate(
frame: learnFrame,
choice: instances,
// Only an instance of Halkyon Learn: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'learn'),
open: (instance) => Dependencies.create(instance, device),
readLook: () async => (await device.deviceSettingsStore.read()).look,
app: const LearnSessions(),
WhatsNewScope(
whatsNew: WhatsNew(
AssetWhatsNewSource(rootBundle),
FileWhatsNewStore(File(p.join(device.support, 'whats_new.json'))),
version: device.appVersion.version,
),
// An instance chosen before: the app ran on this device already.
usedBefore: instances.current != null,
child: InstanceGate(
frame: learnFrame,
choice: instances,
// Only an instance of Halkyon Learn: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'learn'),
open: (instance) => Dependencies.create(instance, device),
readLook: () async => (await device.deviceSettingsStore.read()).look,
app: const LearnSessions(),
),
),
);
}

View file

@ -1,4 +1,5 @@
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -40,26 +41,29 @@ class const AppShell({
child: Icon(data),
)
: Icon(data);
return NavigationFrame(
appName: l10n.appTitle,
// The kingfisher has a white background, which the frame's rounded
// tile keeps reading on light and dark rails alike.
icon: const AssetImage('assets/icon/halkyon_icon.png'),
selectedIndex: navigationShell.currentIndex,
onSelected: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
// After an update, what's new shows once.
return WhatsNewOnUpdate(
child: NavigationFrame(
appName: l10n.appTitle,
// The kingfisher has a white background, which the frame's rounded
// tile keeps reading on light and dark rails alike.
icon: const AssetImage('assets/icon/halkyon_icon.png'),
selectedIndex: navigationShell.currentIndex,
onSelected: (index) => navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
),
hidesNavigationBar: hidesNavigationBar,
destinations: [
for (final destination in destinations)
(
label: destination.label,
icon: icon(destination, destination.icon),
selectedIcon: icon(destination, destination.selectedIcon),
),
],
child: navigationShell,
),
hidesNavigationBar: hidesNavigationBar,
destinations: [
for (final destination in destinations)
(
label: destination.label,
icon: icon(destination, destination.icon),
selectedIcon: icon(destination, destination.selectedIcon),
),
],
child: navigationShell,
);
}
}

View file

@ -106,10 +106,17 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.settingsAbout),
onTap: () => showAbout(context),
child: Column(
children: [
// What changed in this version, as shown once after
// the update (halkyon_app's).
const WhatsNewTile(),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.settingsAbout),
onTap: () => showAbout(context),
),
],
),
),
const SizedBox(height: 24),

View file

@ -187,6 +187,10 @@ flutter:
# https://flutter.dev/assets-and-images/#from-packages
assets:
- assets/config.json
# What each release changed, one file a release and language
# (<version>.<language>.txt), drafted by tool/release.dart and edited
# (Halkyon Train's ADR 0030, D61): halkyon_app's What's new shows them.
- assets/whats_new/
# The kingfisher, Halkyon's emblem: atop the navigation rail and on the launch
# screen.
- assets/icon/halkyon_icon.png

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
- Die erste Version von Halkyon Train: Zeichne dein Training auf, plane Routen und Intervallpläne und behalte deine Tage vom Polar Loop, alles versiegelt.

View file

@ -0,0 +1 @@
- The first release of Halkyon Train: record your workouts, plan routes and interval plans, and keep your days from the Polar Loop, everything sealed.

View file

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/app/licences.dart';
import 'package:halkyon_train_flutter/src/app/train_app.dart';
@ -32,12 +33,21 @@ Future<void> main() async {
);
await instances.load(usedBefore: device.storage.usedBefore());
runApp(
TrainApp(
choice: instances,
// Only an instance of Halkyon Train: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'train'),
open: (instance) => TrainDependencies.create(instance, device),
WhatsNewScope(
whatsNew: WhatsNew(
AssetWhatsNewSource(rootBundle),
FileWhatsNewStore(File(p.join(device.support, 'whats_new.json'))),
version: device.appVersion,
),
// An instance chosen before: the app ran on this device already.
usedBefore: instances.current != null,
child: TrainApp(
choice: instances,
// Only an instance of Halkyon Train: one of another Halkyon app's
// answers too, but holds nothing of this app's.
checker: ServerpodInstanceChecker(app: 'train'),
open: (instance) => TrainDependencies.create(instance, device),
),
),
);
}

View file

@ -133,20 +133,28 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
const SizedBox(height: 12),
SectionCard(
padding: const EdgeInsets.symmetric(vertical: 8),
child: ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle),
// halkyon_app's About: the app's name and icon, the
// source of what the instance runs, which both
// licences ask to offer, and the licence list.
onTap: () => showAppAbout(
context,
icon: const Image(
image: trainIcon,
width: 48,
height: 48,
child: Column(
children: [
// What changed in this version, as shown once after
// the update (halkyon_app's, D61).
const WhatsNewTile(),
ListTile(
leading: const Icon(LucideIcons.info),
title: Text(l10n.aboutTitle),
// halkyon_app's About: the app's name and icon,
// the source of what the instance runs, which
// both licences ask to offer, and the licence
// list.
onTap: () => showAppAbout(
context,
icon: const Image(
image: trainIcon,
width: 48,
height: 48,
),
),
),
),
],
),
),
const SizedBox(height: 24),

View file

@ -21,7 +21,8 @@ import 'package:material_ui/material_ui.dart';
/// off as the app opens is offered to be saved or discarded, whichever tab
/// shows, and one under way stands above the bar on every tab
/// ([RecordingBar]); GPX files Android hands Train are brought in as routes (D45), and
/// Health Connect's links to Train's rationale are followed (D31).
/// Health Connect's links to Train's rationale are followed (D31). After an
/// update, what's new shows once (halkyon_app's [WhatsNewOnUpdate], D61).
class const TrainShell({
/// The tabs' navigators, from `StatefulShellRoute`.
required final StatefulNavigationShell shell,
@ -81,7 +82,7 @@ class const TrainShell({
child: shell,
);
final listening = HealthConnectListener(
child: RouteFilesListener(child: frame),
child: RouteFilesListener(child: WhatsNewOnUpdate(child: frame)),
);
if (!records) {
return listening;

View file

@ -97,6 +97,10 @@ flutter:
# The instance the build names, unless --dart-define=SERVER_URL=… does
# (serverpod_flutter's getServerUrl).
- assets/config.json
# What each release changed, one file a release and language
# (<version>.<language>.txt), drafted by tool/release.dart and edited
# (ADR 0030, D61): halkyon_app's What's new shows them.
- assets/whats_new/
# The app's icon, Lucide's bike (ADR 0031), and Lucide's licence for the
# licence list (the repository's tool/app_icons.py writes both).
- assets/icon/halkyon_train_icon.png

View file

@ -1535,6 +1535,34 @@ Strava. What was found, 27 Oct:
title, which, tapped, scrolls back up to the row. The row alone is the scroll view's
header, nothing pinned, so the tabs' lists need no room kept for it and stay plain
`ListView`s.
- **D61 What's new, in every Halkyon app** (the developer's; 30 and 31 Oct): after an
update, a sheet lists what changed, once per device, and Settings shows it again
(`WhatsNewTile`, in the About card), shared in halkyon_app (`WhatsNew`,
`WhatsNewOnUpdate` around the tabs, `WhatsNewScope` around the app). Before it,
Forgejo's notes listed the feat/fix subjects, and F-Droid and the Snap Store
showed nothing (the Snap Store has no field for it).
- **One file a release and language** in the app's `assets/whats_new/`
(`<version>.<language>.txt`, English and German, a change a line as `- …`),
bundled with the app; over one changelog per language, which the app and the
tool would parse by sections and which grows with each release.
- **Drafted, then edited**: `tool/release.dart`, after *Go on?*, drafts the
missing files from the commits that count (features, then fixes, without types,
scopes and closing references; German the same lines marked `TRANSLATE:`) and
opens them in `$VISUAL`/`$EDITOR` (or waits for Enter) until each lists a change,
has no mark left and fits F-Droid's 500 characters; they go into the release
commit with the version (the first release's alone, where the pubspec keeps its
version). A run stopped there keeps them for the next, the only untracked files
a release starts with. The dry run prints them, or the drafts.
- **The same text everywhere**: the English as the Forgejo notes' *What's new*, in
place of the commits' list, and each language as F-Droid's changelog of the build
(`metadata/<app id>/<locale>/changelogs/<build>.txt`, `en-US` and `de`), written
by the tool at publishing and copied in by `tool/fdroid_repo.sh`
(`HALKYON_FDROID_METADATA`), so nothing is committed twice.
- **Every release since**: someone who skipped updates sees each release's changes
since the version the device showed last (`whats_new.json` in the support
folder), newest first; over the newest alone. Nothing on a fresh install; a device
used before (an instance chosen) is shown the running version's on its first start
with this. In the app's language, else English.
## Consequences

View file

@ -31,6 +31,10 @@
/// the session on `AuthRepository` and `ServerpodAuthGateway`, the
/// account's keys on the device. Every account has a way in: there are no
/// anonymous ones (ADR 0020, D4).
/// - **What's new**: `WhatsNew` reads what each release changed from the
/// app's `assets/whats_new/` (`tool/release.dart` drafts it), and
/// `WhatsNewOnUpdate` shows it once after an update, `WhatsNewTile` in
/// Settings again, both under the app's `WhatsNewScope`.
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
/// `SyncTroubleBadge`).
@ -171,3 +175,5 @@ export 'src/sync/widget/app_mismatch_banner.dart';
export 'src/sync/widget/sync_and_wait.dart';
export 'src/sync/widget/sync_status_card.dart';
export 'src/sync/widget/sync_trouble_badge.dart';
export 'src/whats_new/whats_new.dart';
export 'src/whats_new/whats_new_view.dart';

View file

@ -443,5 +443,7 @@
"assistantProblemProviderRefused": "Der Anbieter hat diese Frage abgelehnt.",
"assistantProblemProviderFailed": "Beim Anbieter ist etwas schiefgegangen. Versuch es noch einmal.",
"assistantProblemNotAllowed": "Das können nur die, für die er freigegeben ist.",
"assistantProblemFailed": "Das hat nicht geklappt. Versuch es noch einmal."
"assistantProblemFailed": "Das hat nicht geklappt. Versuch es noch einmal.",
"whatsNewTitle": "Neuigkeiten",
"whatsNewVersion": "Version {version}"
}

View file

@ -1451,5 +1451,14 @@
"assistantProblemFailed": "That didn't work. Try again.",
"@assistantProblemFailed": {
"description": "failed."
},
"whatsNewTitle": "What's new",
"@whatsNewTitle": {
"description": "The sheet listing what changed in the app after an update, and its entry in Settings."
},
"whatsNewVersion": "Version {version}",
"@whatsNewVersion": {
"description": "Above a release's changes in What's new.",
"placeholders": {"version": {"type": "String"}}
}
}

View file

@ -2766,6 +2766,18 @@ abstract class HalkyonAppLocalizations {
/// In en, this message translates to:
/// **'That didn\'t work. Try again.'**
String get assistantProblemFailed;
/// The sheet listing what changed in the app after an update, and its entry in Settings.
///
/// In en, this message translates to:
/// **'What\'s new'**
String get whatsNewTitle;
/// Above a release's changes in What's new.
///
/// In en, this message translates to:
/// **'Version {version}'**
String whatsNewVersion(String version);
}
class _HalkyonAppLocalizationsDelegate

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