feat(publish): the Publish sheet lists the tag names a release sends (ADR 0019)

Before Publish, the sheet shows the names of the tags that go with the
release, which the instance learns, sorted, or that none go; none where
they can't be had, as something in the tree is private (the short
reading of 22 Oct, finding 5). In English and German.

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-22 10:32:34 +02:00
commit 9841d2d864
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
8 changed files with 137 additions and 8 deletions

View file

@ -2073,6 +2073,9 @@
"studysetReadableBody": "Alles darin wird entschlüsselt und neu synchronisiert, sodass die Instanz es lesen kann: Du kannst es veröffentlichen, und Claude kann es über den Connector lesen.",
"studysetPrivateNoKeys": "Melde dich auf diesem Gerät mit deinem Passwort an, um Lernsets privat zu machen.",
"studysetPrivateSomeLocked": "Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.",
"publishTagsSent": "Die Namen der Tags, die mitgehen und die die Instanz erfährt: {names}",
"@publishTagsSent": {"placeholders": {"names": {"type": "String"}}},
"publishTagsSentNone": "Es gehen keine Tags mit.",
"studysetLockedTitle": "Gesperrt",
"studysetLockedBody": "Dieses Lernset ist privat, und sein Schlüssel öffnet sich auf diesem Gerät nicht: Es wurde mit Schlüsseln verschlüsselt, die dein Konto nicht mehr hat. Es lässt sich nur noch löschen.",
"studysetVisibilityPartly": "Privat, teils lesbar",

View file

@ -2439,6 +2439,9 @@
"studysetReadableBody": "Everything in it is decrypted and synced again, so the instance can read it: you can publish it, and Claude can read it through the connector.",
"studysetPrivateNoKeys": "Sign in with your password on this device to make studysets private.",
"studysetPrivateSomeLocked": "Some of it couldn't be opened on this device and stayed as it was.",
"publishTagsSent": "The names of the tags that go with it, which the instance learns: {names}",
"@publishTagsSent": {"placeholders": {"names": {"type": "String"}}},
"publishTagsSentNone": "No tags go with it.",
"studysetLockedTitle": "Locked",
"studysetLockedBody": "This studyset is private, and its key doesn't open on this device: it was encrypted with keys your account no longer has. It can only be deleted.",
"studysetVisibilityPartly": "Private, partly readable",

View file

@ -12225,6 +12225,18 @@ abstract class AppLocalizations {
/// **'Some of it couldn\'t be opened on this device and stayed as it was.'**
String get studysetPrivateSomeLocked;
/// No description provided for @publishTagsSent.
///
/// In en, this message translates to:
/// **'The names of the tags that go with it, which the instance learns: {names}'**
String publishTagsSent(String names);
/// No description provided for @publishTagsSentNone.
///
/// In en, this message translates to:
/// **'No tags go with it.'**
String get publishTagsSentNone;
/// No description provided for @studysetLockedTitle.
///
/// In en, this message translates to:

View file

@ -7751,6 +7751,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get studysetPrivateSomeLocked =>
'Manches darin ließ sich auf diesem Gerät nicht öffnen und blieb, wie es war.';
@override
String publishTagsSent(String names) {
return 'Die Namen der Tags, die mitgehen und die die Instanz erfährt: $names';
}
@override
String get publishTagsSentNone => 'Es gehen keine Tags mit.';
@override
String get studysetLockedTitle => 'Gesperrt';

View file

@ -7678,6 +7678,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetPrivateSomeLocked =>
'Some of it couldn\'t be opened on this device and stayed as it was.';
@override
String publishTagsSent(String names) {
return 'The names of the tags that go with it, which the instance learns: $names';
}
@override
String get publishTagsSentNone => 'No tags go with it.';
@override
String get studysetLockedTitle => 'Locked';

View file

@ -53,6 +53,13 @@ class PublishRepository(
return publication;
}
/// The names of the tags a release of [studysetId] would send, by their
/// ids, for the person to see before it goes (the short reading of 22
/// Oct); none without names to give. Throws as publishing would where
/// something in it is private.
Future<Map<String, String>> tagNamesOf(String studysetId) async =>
await _tagNames?.call(studysetId) ?? const {};
/// Publishes the studyset [studysetId] as its next release, [notes]
/// saying what changed; at the first, under the name [slug]. Explore
/// shows [description] as its description, when given. Throws

View file

@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_text_screen.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
@ -169,7 +170,10 @@ class _PublishSheetState() extends State<_PublishSheet> {
),
],
),
final PublishStateReady ready => _Form(
final PublishStateReady ready => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Form(
studyset: widget.studyset,
all: widget.all,
state: ready,
@ -178,6 +182,14 @@ class _PublishSheetState() extends State<_PublishSheet> {
about: _aboutIn(ready),
onEditAbout: _editAbout,
),
if (ready.profile != null)
PublishTagNames(
names: PersonDependencies.of(
context,
).publishing.tagNamesOf(widget.studyset.id),
),
],
),
},
footer: switch (state) {
PublishStateReady(profile: _?, :final publishing) => Align(
@ -611,3 +623,37 @@ String _problemText(
PublishProblem.private => l10n.publishPrivateRefused,
PublishProblem.tagNamesMismatch => l10n.publishTagNames,
};
/// The names of the tags a release sends, which the instance learns, shown
/// before Publish (the short reading of 22 Oct): the person sees each name
/// that goes out; none while they can't be had.
class const PublishTagNames({
required final Future<Map<String, String>> names,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return FutureBuilder<Map<String, String>>(
future: names.catchError((Object _) => const <String, String>{}),
builder: (context, snapshot) {
final sent = [...?snapshot.data?.values]..sort();
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
sent.isEmpty
? l10n.publishTagsSentNone
: l10n.publishTagsSent(sent.join(', ')),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
},
);
}
}

View file

@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/app_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/widget/publish_sheet.dart';
import 'package:material_ui/material_ui.dart';
/// The Publish sheet shows the names of the tags a release sends before
/// Publish, as the instance learns each (ADR 0019, the short reading of 22
/// Oct).
void main() {
Future<void> pump(
WidgetTester tester,
Future<Map<String, String>> names,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: appLocalizationsDelegates,
home: Scaffold(body: PublishTagNames(names: names)),
),
);
await tester.pump();
}
testWidgets('lists every name sent, sorted', (tester) async {
await pump(tester, Future.value({'t2': 'Verbs', 't1': 'JLPT-N3'}));
expect(find.textContaining('JLPT-N3, Verbs'), findsOneWidget);
});
testWidgets('says when none go with it', (tester) async {
await pump(tester, Future.value(const {}));
expect(find.text('No tags go with it.'), findsOneWidget);
});
testWidgets('shows no name where they can not be had (something private)', (
tester,
) async {
await pump(tester, Future(() => throw StateError('private')));
expect(find.textContaining('JLPT'), findsNothing);
});
}