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
10 changed files with 1272 additions and 76 deletions

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

View file

@ -1751,4 +1751,12 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get assistantProblemFailed =>
'Das hat nicht geklappt. Versuch es noch einmal.';
@override
String get whatsNewTitle => 'Neuigkeiten';
@override
String whatsNewVersion(String version) {
return 'Version $version';
}
}

View file

@ -1724,6 +1724,14 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get assistantProblemFailed => 'That didn\'t work. Try again.';
@override
String get whatsNewTitle => 'What\'s new';
@override
String whatsNewVersion(String version) {
return 'Version $version';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -0,0 +1,24 @@
import 'package:halkyon_app/src/whats_new/whats_new.dart';
/// What's new from `files`, named as the app bundles them
/// (`<version>.<language>.txt`) and holding their texts.
class MemoryWhatsNewSource(final Map<String, String> _files)
implements WhatsNewSource {
@override
Future<List<String>> files() async => _files.keys.toList();
@override
Future<String> read(String name) async => _files[name]!;
}
/// Keeps the version shown last in memory, [seen] at first.
class MemoryWhatsNewStore({
/// The version kept.
var String? seen,
}) implements WhatsNewStore {
@override
Future<String?> read() async => seen;
@override
Future<void> write(String version) async => seen = version;
}

View file

@ -0,0 +1,194 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:halkyon_core/halkyon_core.dart';
/// A release's changes as the app bundles them: its `version` and one line
/// for each change, in one language.
typedef ReleaseChanges = ({String version, List<String> changes});
/// Where an app's texts of what each release changed come from: one file
/// for each release and language, `<version>.<language>.txt`, one change a
/// line, each starting with `- `, as `tool/release.dart` writes and the
/// developer edits them (Halkyon Train's ADR 0030, D61).
abstract interface class WhatsNewSource() {
/// The names of the files there are.
Future<List<String>> files();
/// The file [name]'s text.
Future<String> read(String name);
}
/// The texts bundled with the app in `assets/whats_new/`.
class AssetWhatsNewSource(final AssetBundle _bundle) implements WhatsNewSource {
static const _folder = 'assets/whats_new/';
@override
Future<List<String>> files() async => [
for (final asset in (await AssetManifest.loadFromAssetBundle(
_bundle,
)).listAssets())
if (asset.startsWith(_folder)) asset.substring(_folder.length),
];
@override
Future<String> read(String name) => _bundle.loadString('$_folder$name');
}
/// Which version's changes the device showed last.
abstract interface class WhatsNewStore() {
/// The version; null where none was ever shown, or it can't be read.
Future<String?> read();
/// Keeps [version].
Future<void> write(String version);
}
/// Keeps it as JSON in [_file], in the app's support folder: it is the
/// device's, not a person's. Written to a temporary file first and renamed
/// into place.
class FileWhatsNewStore(final File _file) implements WhatsNewStore {
final _queue = SerialQueue();
@override
Future<String?> read() async {
try {
return switch (jsonDecode(await _file.readAsString())) {
{'seen': final String seen} => seen,
_ => null,
};
} on Exception {
return null;
}
}
@override
Future<void> write(String version) => _queue.run(() async {
final temporary = File('${_file.path}.tmp');
await temporary.writeAsString(jsonEncode({'seen': version}), flush: true);
await temporary.rename(_file.path);
});
}
/// What changed in the app, from [_source], for the version that runs
/// ([version]), with what the device showed last in [_store].
class WhatsNew(
final WhatsNewSource _source,
final WhatsNewStore _store, {
/// The version that runs, `<major>.<minor>.<patch>`.
required final String version,
}) {
static final _file = RegExp(r'^(\d+)\.(\d+)\.(\d+)\.([a-z]{2,3})\.txt$');
/// The changes the person hasn't seen, newest first, in [language] (else
/// English, else whichever there is): every release after the one the
/// device showed last, up to [version]; nothing on a fresh install, where
/// nothing changed for them. A device that ran the app before it showed
/// what's new ([usedBefore], with an account on it) is shown [version]'s
/// alone. Once asked, the device counts them as seen.
Future<List<ReleaseChanges>> unseen({
required String language,
required bool usedBefore,
}) async {
final current = _parse(version);
if (current == null) {
return const [];
}
final seen = switch (await _store.read()) {
final seen? => _parse(seen),
null => null,
};
if (seen != null && _compare(seen, current) >= 0) {
if (_compare(seen, current) > 0) {
await _store.write(version);
}
return const [];
}
await _store.write(version);
if (seen == null && !usedBefore) {
return const [];
}
return await _releases(
language,
(release) =>
_compare(release, current) <= 0 &&
(seen == null
? _compare(release, current) == 0
: _compare(release, seen) > 0),
);
}
/// The changes of [version] itself, in [language] as [unseen] picks it;
/// null where the app bundles none.
Future<ReleaseChanges?> current({required String language}) async {
final current = _parse(version);
if (current == null) {
return null;
}
final releases = await _releases(
language,
(release) => _compare(release, current) == 0,
);
return releases.firstOrNull;
}
Future<List<ReleaseChanges>> _releases(
String language,
bool Function(List<int> release) wanted,
) async {
// Each release's files by language.
final byRelease = <String, Map<String, String>>{};
for (final name in await _source.files()) {
if (_file.firstMatch(name) case final match?) {
final release = '${match[1]}.${match[2]}.${match[3]}';
(byRelease[release] ??= {})[match[4]!] = name;
}
}
final releases = byRelease.keys.where((r) => wanted(_parse(r)!)).toList()
..sort((a, b) => _compare(_parse(b)!, _parse(a)!));
return [
for (final release in releases)
if (await _read(byRelease[release]!, language) case final changes
when changes.isNotEmpty)
(version: release, changes: changes),
];
}
Future<List<String>> _read(Map<String, String> files, String language) async {
final name =
files[language] ?? files['en'] ?? files.values.firstOrNull ?? '';
try {
return changesOf(await _source.read(name));
} on Exception {
return const [];
}
}
static List<int>? _parse(String version) {
final parts = version.split('+').first.split('.');
if (parts.length != 3) {
return null;
}
final numbers = [for (final part in parts) int.tryParse(part)];
return numbers.contains(null) ? null : numbers.cast<int>();
}
static int _compare(List<int> a, List<int> b) {
for (var i = 0; i < 3; i++) {
if (a[i] != b[i]) {
return a[i].compareTo(b[i]);
}
}
return 0;
}
}
/// The changes a file of what's new lists: its lines starting with `- `,
/// without it.
List<String> changesOf(String text) => [
for (final line in const LineSplitter().convert(text))
if (line.trim() case final line when line.startsWith('- '))
line.substring(2).trim(),
];

View file

@ -0,0 +1,152 @@
import 'dart:async';
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
import 'package:halkyon_app/src/whats_new/whats_new.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Hands the app's [whatsNew] down, with whether the device ran the app
/// before it showed what's new ([usedBefore]): an app puts it around its
/// router, made once as it starts. Without one, nothing shows.
class const WhatsNewScope({
required final WhatsNew whatsNew,
required final bool usedBefore,
required super.child,
super.key,
}) extends InheritedWidget {
/// The scope above [context], if any.
static WhatsNewScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<WhatsNewScope>();
@override
bool updateShouldNotify(WhatsNewScope oldWidget) =>
whatsNew != oldWidget.whatsNew || usedBefore != oldWidget.usedBefore;
}
/// Shows what changed since the device showed it last, once, as [child]
/// (an app's tabs) first shows after an update ([WhatsNew.unseen]).
class const WhatsNewOnUpdate({required final Widget child, super.key})
extends StatefulWidget {
@override
State<WhatsNewOnUpdate> createState() => _WhatsNewOnUpdateState();
}
class _WhatsNewOnUpdateState() extends State<WhatsNewOnUpdate> {
bool _asked = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_asked) {
return;
}
_asked = true;
final scope = WhatsNewScope.maybeOf(context);
if (scope != null) {
unawaited(_show(scope));
}
}
Future<void> _show(WhatsNewScope scope) async {
final releases = await scope.whatsNew.unseen(
language: Localizations.localeOf(context).languageCode,
usedBefore: scope.usedBefore,
);
if (releases.isNotEmpty && mounted) {
await showWhatsNew(context, releases);
}
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Shows [releases]' changes in a sheet, newest first, each under its
/// version.
Future<void> showWhatsNew(
BuildContext context,
List<ReleaseChanges> releases,
) => showAdaptiveSheet<void>(
context,
builder: (context) {
final l10n = HalkyonAppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.whatsNewTitle,
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (index, release) in releases.indexed) ...[
Padding(
padding: EdgeInsets.only(top: index == 0 ? 0 : 20, bottom: 8),
child: Text(
l10n.whatsNewVersion(release.version),
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
for (final change in release.changes)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2, right: 12),
child: Icon(
LucideIcons.sparkle,
size: 16,
color: theme.colorScheme.primary,
),
),
Expanded(child: Text(change)),
],
),
),
],
],
),
);
},
);
/// What's new in the version that runs, for an app's settings: tapped, its
/// changes in the sheet. Nothing where the app bundles none for it, or has
/// no [WhatsNewScope].
class const WhatsNewTile({super.key}) extends StatefulWidget {
@override
State<WhatsNewTile> createState() => _WhatsNewTileState();
}
class _WhatsNewTileState() extends State<WhatsNewTile> {
Future<ReleaseChanges?>? _current;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_current ??= WhatsNewScope.maybeOf(context)?.whatsNew.current(
language: Localizations.localeOf(context).languageCode,
);
}
@override
Widget build(BuildContext context) => FutureBuilder<ReleaseChanges?>(
future: _current,
builder: (context, snapshot) {
final release = snapshot.data;
if (release == null) {
return const SizedBox.shrink();
}
final l10n = HalkyonAppLocalizations.of(context);
return ListTile(
leading: const Icon(LucideIcons.sparkles),
title: Text(l10n.whatsNewTitle),
subtitle: Text(l10n.whatsNewVersion(release.version)),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () => showWhatsNew(context, [release]),
);
},
);
}

View file

@ -24,4 +24,5 @@ export 'src/testing/memory_file_saver.dart';
export 'src/testing/memory_instance_store.dart';
export 'src/testing/memory_pending_changes_store.dart';
export 'src/testing/memory_sign_in_addresses.dart';
export 'src/testing/memory_whats_new.dart';
export 'src/testing/test_app.dart';