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:
parent
581cf1f1cd
commit
7727317c68
10 changed files with 1272 additions and 76 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
24
packages/halkyon_app/lib/src/testing/memory_whats_new.dart
Normal file
24
packages/halkyon_app/lib/src/testing/memory_whats_new.dart
Normal 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;
|
||||
}
|
||||
194
packages/halkyon_app/lib/src/whats_new/whats_new.dart
Normal file
194
packages/halkyon_app/lib/src/whats_new/whats_new.dart
Normal 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(),
|
||||
];
|
||||
152
packages/halkyon_app/lib/src/whats_new/whats_new_view.dart
Normal file
152
packages/halkyon_app/lib/src/whats_new/whats_new_view.dart
Normal 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]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue