feat(learn): confirm the notes and files a release takes

ADR 0044, D8 and D9, its first step: before publishing to Explore, the
person confirms the notes, PDFs and other files that go. Cards and
learning paths always go.

Publish's Continue leads to a step listing them by kind, none ticked at
first but those the latest release held. Each kind is ticked whole, and
an item's menu ticks everything but it or nothing but it. What isn't
ticked goes to the server as publish's leftOut. So does whatever the
sync before publishing brings that the person never saw.

The server leaves those out of the release and its counts, and leaves
out the files only they name. leftOut is optional: released apps send
none and publish everything, as before. publish.releasedMaterial names
the notes and files of the latest release, and none for a studyset not
the caller's, as for one never published.

Whats-New: Before publishing to Explore, you choose which notes and files go with the cards and learning paths.
Whats-New-De: Vor dem Veröffentlichen auf Explore wählst du, welche Notizen und Dateien mit den Karteikarten und Lernpfaden mitgehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 01:54:16 +01:00
commit d664115c46
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 1202 additions and 38 deletions

View file

@ -2200,5 +2200,16 @@
"readerShowSource": "Quelltext zeigen",
"readerShowFormatted": "Formatiert zeigen",
"studysetSheetMakingPrivate": "Alles darin wird verschlüsselt … Das kann eine Minute dauern; du kannst das hier solange schließen.",
"studysetSheetMakingReadable": "Alles darin wird lesbar gemacht … Das kann eine Minute dauern; du kannst das hier solange schließen."
"studysetSheetMakingReadable": "Alles darin wird lesbar gemacht … Das kann eine Minute dauern; du kannst das hier solange schließen.",
"publishContinue": "Weiter",
"publishBack": "Zurück",
"publishMaterialIntro": "Karteikarten und Lernpfade gehen immer mit. Hake die Notizen und Dateien an, die mit veröffentlicht werden sollen; der Rest bleibt nur deiner.",
"publishMaterialNone": "Es enthält keine Notizen oder Dateien: seine Karteikarten und Lernpfade gehen mit.",
"publishMaterialNotes": "Notizen ({count})",
"publishMaterialPdfs": "PDFs ({count})",
"publishMaterialFiles": "Andere Dateien ({count})",
"publishMaterialAllBut": "Alles außer diesem",
"publishMaterialOnlyThis": "Nur dieses",
"publishMaterialOptions": "Damit auswählen",
"publishMaterialCount": "{count, plural, =0{Keine Notizen oder Dateien gehen mit} =1{1 Notiz oder Datei geht mit} other{{count} Notizen und Dateien gehen mit}}"
}

View file

@ -2730,5 +2730,27 @@
"studysetSheetMakingPrivate": "Encrypting everything in it… This can take a minute; you can close this meanwhile.",
"@studysetSheetMakingPrivate": {"description": "Below the Private switch of a studyset's sheet while it is being made private"},
"studysetSheetMakingReadable": "Making everything in it readable… This can take a minute; you can close this meanwhile.",
"@studysetSheetMakingReadable": {"description": "Below the Private switch of a studyset's sheet while it is being made readable by the instance"}
"@studysetSheetMakingReadable": {"description": "Below the Private switch of a studyset's sheet while it is being made readable by the instance"},
"publishContinue": "Continue",
"@publishContinue": {"description": "The Publish sheet's button leading to the step confirming the notes and files that go"},
"publishBack": "Back",
"@publishBack": {"description": "Leaves the Publish sheet's step confirming the material, back to its form"},
"publishMaterialIntro": "Cards and learning paths always go. Tick the notes and files to publish with them; the rest stays yours alone.",
"@publishMaterialIntro": {"description": "Above the list of notes and files confirmed before publishing"},
"publishMaterialNone": "It holds no notes or files: its cards and learning paths go.",
"@publishMaterialNone": {"description": "The step confirming the material, for a studyset without notes or files"},
"publishMaterialNotes": "Notes ({count})",
"@publishMaterialNotes": {"description": "Heading of the notes to tick before publishing, ticking or unticking all of them", "placeholders": {"count": {"type": "int"}}},
"publishMaterialPdfs": "PDFs ({count})",
"@publishMaterialPdfs": {"description": "Heading of the PDFs to tick before publishing, ticking or unticking all of them", "placeholders": {"count": {"type": "int"}}},
"publishMaterialFiles": "Other files ({count})",
"@publishMaterialFiles": {"description": "Heading of the files other than PDFs (Markdown) to tick before publishing", "placeholders": {"count": {"type": "int"}}},
"publishMaterialAllBut": "Everything but this",
"@publishMaterialAllBut": {"description": "Menu item on a note or file: ticks every note and file but this one"},
"publishMaterialOnlyThis": "Nothing but this",
"@publishMaterialOnlyThis": {"description": "Menu item on a note or file: ticks only this one"},
"publishMaterialOptions": "Choose with this",
"@publishMaterialOptions": {"description": "Tooltip of a note's or file's menu in the step confirming the material"},
"publishMaterialCount": "{count, plural, =0{No notes or files go} =1{1 note or file goes} other{{count} notes and files go}}",
"@publishMaterialCount": {"description": "Below the material confirmed before publishing: how many go", "placeholders": {"count": {"type": "int"}}}
}

View file

@ -12985,6 +12985,72 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Making everything in it readable… This can take a minute; you can close this meanwhile.'**
String get studysetSheetMakingReadable;
/// The Publish sheet's button leading to the step confirming the notes and files that go
///
/// In en, this message translates to:
/// **'Continue'**
String get publishContinue;
/// Leaves the Publish sheet's step confirming the material, back to its form
///
/// In en, this message translates to:
/// **'Back'**
String get publishBack;
/// Above the list of notes and files confirmed before publishing
///
/// In en, this message translates to:
/// **'Cards and learning paths always go. Tick the notes and files to publish with them; the rest stays yours alone.'**
String get publishMaterialIntro;
/// The step confirming the material, for a studyset without notes or files
///
/// In en, this message translates to:
/// **'It holds no notes or files: its cards and learning paths go.'**
String get publishMaterialNone;
/// Heading of the notes to tick before publishing, ticking or unticking all of them
///
/// In en, this message translates to:
/// **'Notes ({count})'**
String publishMaterialNotes(int count);
/// Heading of the PDFs to tick before publishing, ticking or unticking all of them
///
/// In en, this message translates to:
/// **'PDFs ({count})'**
String publishMaterialPdfs(int count);
/// Heading of the files other than PDFs (Markdown) to tick before publishing
///
/// In en, this message translates to:
/// **'Other files ({count})'**
String publishMaterialFiles(int count);
/// Menu item on a note or file: ticks every note and file but this one
///
/// In en, this message translates to:
/// **'Everything but this'**
String get publishMaterialAllBut;
/// Menu item on a note or file: ticks only this one
///
/// In en, this message translates to:
/// **'Nothing but this'**
String get publishMaterialOnlyThis;
/// Tooltip of a note's or file's menu in the step confirming the material
///
/// In en, this message translates to:
/// **'Choose with this'**
String get publishMaterialOptions;
/// Below the material confirmed before publishing: how many go
///
/// In en, this message translates to:
/// **'{count, plural, =0{No notes or files go} =1{1 note or file goes} other{{count} notes and files go}}'**
String publishMaterialCount(int count);
}
class _AppLocalizationsDelegate

View file

@ -8318,4 +8318,54 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetSheetMakingReadable =>
'Alles darin wird lesbar gemacht … Das kann eine Minute dauern; du kannst das hier solange schließen.';
@override
String get publishContinue => 'Weiter';
@override
String get publishBack => 'Zurück';
@override
String get publishMaterialIntro =>
'Karteikarten und Lernpfade gehen immer mit. Hake die Notizen und Dateien an, die mit veröffentlicht werden sollen; der Rest bleibt nur deiner.';
@override
String get publishMaterialNone =>
'Es enthält keine Notizen oder Dateien: seine Karteikarten und Lernpfade gehen mit.';
@override
String publishMaterialNotes(int count) {
return 'Notizen ($count)';
}
@override
String publishMaterialPdfs(int count) {
return 'PDFs ($count)';
}
@override
String publishMaterialFiles(int count) {
return 'Andere Dateien ($count)';
}
@override
String get publishMaterialAllBut => 'Alles außer diesem';
@override
String get publishMaterialOnlyThis => 'Nur dieses';
@override
String get publishMaterialOptions => 'Damit auswählen';
@override
String publishMaterialCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Notizen und Dateien gehen mit',
one: '1 Notiz oder Datei geht mit',
zero: 'Keine Notizen oder Dateien gehen mit',
);
return '$_temp0';
}
}

View file

@ -8244,6 +8244,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetSheetMakingReadable =>
'Making everything in it readable… This can take a minute; you can close this meanwhile.';
@override
String get publishContinue => 'Continue';
@override
String get publishBack => 'Back';
@override
String get publishMaterialIntro =>
'Cards and learning paths always go. Tick the notes and files to publish with them; the rest stays yours alone.';
@override
String get publishMaterialNone =>
'It holds no notes or files: its cards and learning paths go.';
@override
String publishMaterialNotes(int count) {
return 'Notes ($count)';
}
@override
String publishMaterialPdfs(int count) {
return 'PDFs ($count)';
}
@override
String publishMaterialFiles(int count) {
return 'Other files ($count)';
}
@override
String get publishMaterialAllBut => 'Everything but this';
@override
String get publishMaterialOnlyThis => 'Nothing but this';
@override
String get publishMaterialOptions => 'Choose with this';
@override
String publishMaterialCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count notes and files go',
one: '1 note or file goes',
zero: 'No notes or files go',
);
return '$_temp0';
}
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -7,8 +7,11 @@ import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/release_material.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
/// Publishing the studyset [_studysetId]: where it stands, and its next
@ -20,12 +23,18 @@ import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.d
/// instance doesn't: those whose changes merge are saved merged here, and
/// publishing tries once more; those changed differently on the same side
/// are left to the person, as [PublishStateReady.unsettled].
///
/// Before a release the person confirms its notes, PDFs and other files
/// (ADR 0044, D8 and D9, [PublishStateReady.material]): those not ticked
/// stay out, and so does whatever the sync before publishing brings that
/// the person never saw.
class PublishBloc(
final PublishRepository _publishing,
final PublicProfileRepository _profiles,
final CardRepository _cards,
final NoteRepository _notes,
final LearningPathRepository _paths, {
final LearningPathRepository _paths,
final PdfSourceRepository _pdfs, {
required final String _studysetId,
required final Future<void> Function() _sync,
}) extends Bloc<PublishEvent, PublishState> {
@ -33,8 +42,70 @@ class PublishBloc(
on<PublishEventStarted>(_onStarted);
on<PublishEventRequested>(_onRequested);
on<PublishEventWithdrawn>(_onWithdrawn);
on<PublishEventMaterialAsked>(_onMaterialAsked);
on<PublishEventMaterialChosen>(_onMaterialChosen);
}
Future<void> _onMaterialAsked(
PublishEventMaterialAsked event,
Emitter<PublishState> emit,
) async {
if (state is! PublishStateReady) {
return;
}
final (items, released) = await (
_materialOf(event.subtree),
_released(),
).wait;
if (state case final PublishStateReady ready) {
emit(ready.withMaterial(MaterialChoice.initial(items, released)));
}
}
void _onMaterialChosen(
PublishEventMaterialChosen event,
Emitter<PublishState> emit,
) {
if (state case final PublishStateReady ready) {
emit(ready.withMaterial(event.choice));
}
}
/// What the latest release held; none where it can't be read, which
/// leaves everything unticked.
Future<Set<String>> _released() async {
try {
return await _publishing.releasedMaterial(_studysetId);
} on Object catch (error) {
log('Reading the release failed', name: 'Publish', error: error);
return const {};
}
}
/// The notes, PDFs and other files of the studysets [subtree], in the
/// order the studysets and their lists come.
Future<List<MaterialItem>> _materialOf(List<String> subtree) async => [
for (final studysetId in subtree) ...[
for (final note in await _notes.notesOf(studysetId))
MaterialItem(
id: note.id,
kind: MaterialKind.note,
title: note.content.title,
studysetId: studysetId,
),
for (final source in await _pdfs.sourcesOf(studysetId))
MaterialItem(
id: source.id,
kind: switch (source.content.kind) {
FileKind.pdf => MaterialKind.pdf,
FileKind.markdown => MaterialKind.file,
},
title: source.content.title,
studysetId: studysetId,
),
],
];
Future<void> _onWithdrawn(
PublishEventWithdrawn event,
Emitter<PublishState> emit,
@ -81,10 +152,17 @@ class PublishBloc(
emit(ready.next(publishing: true));
Future<Publication> publish() async {
await _sync();
// What the sync brought, the person never saw: it stays out.
final ticked = ready.material?.ticked ?? const <String>{};
final leftOut = [
for (final item in await _materialOf(event.subtree))
if (!ticked.contains(item.id)) item.id,
];
return await _publishing.publish(
_studysetId,
slug: event.slug,
notes: event.notes,
leftOut: leftOut,
description: event.description,
tagNamesShown: event.tagNames,
);
@ -236,7 +314,9 @@ final class const PublishStateUnreachable() extends PublishState;
/// was), the person's public [profile] (null while they have none), whether
/// a release is on its way, why the last try was refused ([problem], about
/// [items]; the cards, notes and steps to settle as [unsettled]), and what
/// it came to ([outcome]); the last four cleared by the next try.
/// it came to ([outcome]); the last four cleared by the next try. The
/// notes, PDFs and other files going with the next release are [material],
/// null until asked for.
final class const PublishStateReady({
final Publication? publication,
final PublicProfile? profile,
@ -246,6 +326,7 @@ final class const PublishStateReady({
final String? license,
final List<UnsettledItem> unsettled = const [],
final PublishOutcome? outcome,
final MaterialChoice? material,
}) extends PublishState {
/// This state with [publishing], [problem], [items], [license],
/// [unsettled] and [outcome] as given.
@ -265,6 +346,19 @@ final class const PublishStateReady({
license: license,
unsettled: unsettled,
outcome: outcome,
material: material,
);
/// This state with [material] chosen, the last try's outcome cleared.
PublishStateReady withMaterial(MaterialChoice material) => PublishStateReady(
publication: publication,
profile: profile,
publishing: publishing,
problem: problem,
items: items,
license: license,
unsettled: unsettled,
material: material,
);
@override
@ -277,6 +371,7 @@ final class const PublishStateReady({
license,
unsettled,
outcome,
material,
];
}
@ -292,6 +387,23 @@ final class const PublishEventStarted() extends PublishEvent;
/// The person takes the studyset off Explore.
final class const PublishEventWithdrawn() extends PublishEvent;
/// The person is to confirm the material of the next release: read the
/// notes, PDFs and other files of the studysets [subtree] (the studyset
/// and those inside it).
final class const PublishEventMaterialAsked({
required final List<String> subtree,
}) extends PublishEvent {
@override
List<Object?> get props => [subtree];
}
/// The person changed which material goes, to [choice].
final class const PublishEventMaterialChosen(final MaterialChoice choice)
extends PublishEvent {
@override
List<Object?> get props => [choice];
}
/// The person publishes the next release, named [slug] (fixed after the
/// first), [notes] saying what changed; [subtree] the studyset and those
/// inside it, where the cards, notes and steps to settle are looked for.

View file

@ -11,13 +11,19 @@ abstract interface class PublishGateway() {
/// id.
Future<Set<String>> published();
/// The notes, PDFs and other files the latest release of the studyset
/// [studysetId] holds, by id; none while it was never published.
Future<Set<String>> releasedMaterial(String studysetId);
/// Publishes the studyset [studysetId] as it is on the instance as its
/// next release; at the first, under the name [slug]. Throws
/// [PublishRefused] when the instance refuses.
/// next release, without the notes, PDFs and other files in [leftOut];
/// at the first, under the name [slug]. Throws [PublishRefused] when the
/// instance refuses.
Future<Publication> publish(
String studysetId, {
required String slug,
required String notes,
required List<String> leftOut,
RichContent? description,
Map<String, String>? tagNames,
});

View file

@ -60,8 +60,14 @@ class PublishRepository(
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
/// The notes, PDFs and other files the latest release of the studyset
/// [studysetId] holds, by id, which the next one's confirmation ticks
/// from the start; none while it was never published.
Future<Set<String>> releasedMaterial(String studysetId) =>
_gateway.releasedMaterial(studysetId);
/// Publishes the studyset [studysetId] as its next release without the
/// notes, PDFs and other files in [leftOut], [notes] saying what changed; at the first, under the name [slug]. Explore
/// shows [description] as its description, when given. Throws
/// [PublishRefused] when the instance refuses, or here with
/// [PublishProblem.tagNamesChanged] when the names of the tags it would
@ -74,6 +80,7 @@ class PublishRepository(
required String slug,
required String notes,
required Map<String, String>? tagNamesShown,
List<String> leftOut = const [],
RichContent? description,
}) async {
final names = await _tagNames?.call(studysetId);
@ -84,6 +91,7 @@ class PublishRepository(
studysetId,
slug: slug.trim(),
notes: notes.trim(),
leftOut: leftOut,
description: description,
tagNames: names,
);

View file

@ -22,11 +22,20 @@ class ServerpodPublishGateway(final protocol.Client _client)
for (final id in await _client.publish.published()) id.uuid,
};
@override
Future<Set<String>> releasedMaterial(String studysetId) async => {
for (final id in await _client.publish.releasedMaterial(
protocol.UuidValue.withValidation(studysetId),
))
id.uuid,
};
@override
Future<Publication> publish(
String studysetId, {
required String slug,
required String notes,
required List<String> leftOut,
RichContent? description,
Map<String, String>? tagNames,
}) async {
@ -41,6 +50,9 @@ class ServerpodPublishGateway(final protocol.Client _client)
null => null,
},
tagNames: tagNames,
leftOut: [
for (final id in leftOut) protocol.UuidValue.withValidation(id),
],
),
);
} on protocol.PublishRefusedException catch (error) {

View file

@ -0,0 +1,92 @@
import 'package:equatable/equatable.dart';
/// The kinds of material a person confirms before it is published (ADR
/// 0044, D8), in the order listed; cards and learning paths always go.
enum MaterialKind() {
note,
pdf,
/// A file other than a PDF (Markdown).
file,
}
/// A note, PDF or other file of the studyset or one inside it, [title] as
/// the library shows it, in the studyset [studysetId].
final class const MaterialItem({
required final String id,
required final MaterialKind kind,
required final String title,
required final String studysetId,
}) with Equatable {
@override
List<Object?> get props => [id, kind, title, studysetId];
}
/// Which of [items] go with the release: those [ticked] (ADR 0044, D9).
final class const MaterialChoice({
required final List<MaterialItem> items,
required final Set<String> ticked,
}) with Equatable {
/// Nothing ticked at first but what the latest release held already,
/// [released]: what is out stays out unless the person takes it back.
factory initial(List<MaterialItem> items, Set<String> released) =>
MaterialChoice(
items: items,
ticked: {
for (final item in items)
if (released.contains(item.id)) item.id,
},
);
/// The items of [kind], in the order listed.
List<MaterialItem> of(MaterialKind kind) => [
for (final item in items)
if (item.kind == kind) item,
];
/// Whether every item of [kind] is ticked: true for all, false for none,
/// null for some.
bool? allOf(MaterialKind kind) {
final ofKind = of(kind);
final count = ofKind.where((item) => ticked.contains(item.id)).length;
return count == ofKind.length
? true
: count == 0
? false
: null;
}
/// This choice with every item of [kind] ticked, or none of them.
MaterialChoice withKind(MaterialKind kind, {required bool ticked}) {
final ids = {for (final item in of(kind)) item.id};
return _with(
ticked ? {...this.ticked, ...ids} : this.ticked.difference(ids),
);
}
/// This choice with [id] ticked if it wasn't, and not if it was.
MaterialChoice toggled(String id) => _with(
ticked.contains(id) ? ticked.difference({id}) : {...ticked, id},
);
/// Every item ticked but [id], of every kind.
MaterialChoice allBut(String id) => _with({
for (final item in items)
if (item.id != id) item.id,
});
/// Only [id] ticked, of every kind.
MaterialChoice onlyThis(String id) => _with({id});
/// The items that stay out of the release.
List<String> get leftOut => [
for (final item in items)
if (!ticked.contains(item.id)) item.id,
];
MaterialChoice _with(Set<String> ticked) =>
MaterialChoice(items: items, ticked: ticked);
@override
List<Object?> get props => [items, ticked];
}

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/open_license.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/release_material.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -23,7 +24,8 @@ import 'package:material_ui/material_ui.dart';
/// Shows the Publish sheet of [studyset], one of [all] the person's
/// studysets: whether it is published and where, its name, whether its
/// licence allows publishing, what changed, and its releases so far, with
/// Publish below. Publishing goes through [bloc], or else the [PublishBloc]
/// Continue below, to the step confirming the notes and files that go
/// (ADR 0044, D8 and D9), and Publish there. Publishing goes through [bloc], or else the [PublishBloc]
/// above [context].
Future<void> showPublishSheet(
BuildContext context, {
@ -66,6 +68,9 @@ class _PublishSheetState() extends State<_PublishSheet> {
/// else (the final reading of 22 Oct).
Map<String, String>? _tagNamesShown;
/// Whether the step confirming the material shows, in the form's place.
var _confirming = false;
/// What Explore shows as its description, once changed here; until then
/// the latest release's, else the studyset's own ([_aboutIn]).
RichContent? _about;
@ -98,6 +103,15 @@ class _PublishSheetState() extends State<_PublishSheet> {
super.dispose();
}
void _confirm() {
setState(() => _confirming = true);
context.read<PublishBloc>().add(
PublishEventMaterialAsked(
subtree: subtreeOf(widget.studyset.id, widget.all),
),
);
}
void _publish() {
final bloc = context.read<PublishBloc>();
bloc.add(
@ -117,9 +131,21 @@ class _PublishSheetState() extends State<_PublishSheet> {
return BlocConsumer<PublishBloc, PublishState>(
listenWhen: (before, now) =>
now is PublishStateReady &&
(now.outcome != null ||
(now.problem == PublishProblem.tagNamesChanged && before != now)),
(now.outcome != null || (now.problem != null && before != now)),
listener: (context, state) {
// A refusal is said in the form, as is a release that went out.
if (state
case PublishStateReady(
:final problem,
:final outcome,
)
when problem != null || outcome == PublishOutcome.published) {
setState(() => _confirming = false);
}
if (state case PublishStateReady(:final problem, outcome: null)
when problem != PublishProblem.tagNamesChanged) {
return;
}
if (state case PublishStateReady(
problem: PublishProblem.tagNamesChanged,
outcome: null,
@ -172,6 +198,12 @@ class _PublishSheetState() extends State<_PublishSheet> {
),
],
),
final PublishStateReady ready when _confirming => _MaterialStep(
studyset: widget.studyset,
all: widget.all,
choice: ready.material,
enabled: !ready.publishing,
),
final PublishStateReady ready => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -198,18 +230,35 @@ class _PublishSheetState() extends State<_PublishSheet> {
),
},
footer: switch (state) {
PublishStateReady(profile: _?, :final publishing, :final material)
when _confirming =>
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: publishing
? null
: () => setState(() => _confirming = false),
child: Text(l10n.publishBack),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: publishing || material == null ? null : _publish,
icon: publishing
? const ButtonLoadingIndicator()
: const Icon(LucideIcons.globe),
label: Text(
l10n.publishButton(state.publication?.nextVersion ?? 1),
),
),
],
),
PublishStateReady(profile: _?, :final publishing) => Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton.icon(
onPressed: publishing ? null : _publish,
icon: publishing
? const ButtonLoadingIndicator()
: const Icon(LucideIcons.globe),
label: Text(
l10n.publishButton(
state.publication?.nextVersion ?? 1,
),
),
onPressed: publishing ? null : _confirm,
icon: const Icon(LucideIcons.arrowRight),
label: Text(l10n.publishContinue),
),
),
_ => null,
@ -219,6 +268,113 @@ class _PublishSheetState() extends State<_PublishSheet> {
}
}
/// The step confirming the notes, PDFs and other files of the next
/// release (ADR 0044, D8 and D9): each kind with a box ticking all of it,
/// each item with its own and a menu choosing everything but it or nothing
/// but it; [choice] null while read.
class const _MaterialStep({
required final Studyset studyset,
required final List<Studyset> all,
required final MaterialChoice? choice,
required final bool enabled,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final choice = this.choice;
if (choice == null) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: AppLoadingIndicator()),
);
}
if (choice.items.isEmpty) {
return Text(l10n.publishMaterialNone);
}
final titles = {for (final studyset in all) studyset.id: studyset};
void choose(MaterialChoice next) =>
context.read<PublishBloc>().add(PublishEventMaterialChosen(next));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.publishMaterialIntro),
for (final kind in MaterialKind.values)
if (choice.of(kind) case final items when items.isNotEmpty) ...[
const SizedBox(height: 12),
CheckboxListTile(
key: ValueKey('material-kind-${kind.name}'),
tristate: true,
value: choice.allOf(kind),
enabled: enabled,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
title: Text(
switch (kind) {
MaterialKind.note => l10n.publishMaterialNotes(items.length),
MaterialKind.pdf => l10n.publishMaterialPdfs(items.length),
MaterialKind.file => l10n.publishMaterialFiles(items.length),
},
style: theme.textTheme.titleSmall,
),
onChanged: (_) => choose(
choice.withKind(kind, ticked: choice.allOf(kind) != true),
),
),
for (final item in items)
CheckboxListTile(
key: ValueKey('material-${item.id}'),
value: choice.ticked.contains(item.id),
enabled: enabled,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsetsDirectional.only(start: 16),
title: Text(
item.title.trim().isEmpty ? l10n.publishUntitled : item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: item.studysetId == studyset.id
? null
: Text(
titles[item.studysetId]?.content.title ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
secondary: MenuAnchor(
menuChildren: [
MenuItemButton(
onPressed: () => choose(choice.allBut(item.id)),
child: Text(l10n.publishMaterialAllBut),
),
MenuItemButton(
onPressed: () => choose(choice.onlyThis(item.id)),
child: Text(l10n.publishMaterialOnlyThis),
),
],
builder: (context, menu, _) => IconButton(
key: ValueKey('material-menu-${item.id}'),
tooltip: l10n.publishMaterialOptions,
onPressed: enabled
? () => menu.isOpen ? menu.close() : menu.open()
: null,
icon: const Icon(LucideIcons.ellipsisVertical),
),
),
onChanged: (_) => choose(choice.toggled(item.id)),
),
],
const SizedBox(height: 12),
Text(
l10n.publishMaterialCount(choice.ticked.length),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
}
}
class const _Form({
required final Studyset studyset,
required final List<Studyset> all,

View file

@ -112,6 +112,7 @@ PublishBloc _publishBloc(BuildContext context, String studysetId) {
person.cards,
person.notes,
person.paths,
person.pdfSources,
studysetId: studysetId,
sync: () => syncAndWait(sync),
)..add(const PublishEventStarted());

View file

@ -2,19 +2,27 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/bloc/publish_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/release_material.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/unsettled_item.dart';
import '../../support/fake_asset_gateway.dart';
import '../../support/fake_asset_sealing.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_learning_path_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/memory_published_store.dart';
@ -29,11 +37,31 @@ void main() {
late CardRepository cards;
late List<String> calls;
/// What the sync before publishing brings, when set.
Future<void> Function()? onSync;
late NoteRepository notes;
late PdfSourceRepository pdfs;
setUp(() {
publishing = FakePublishGateway();
cardGateway = FakeCardGateway();
cards = CardRepository(cardGateway);
calls = [];
onSync = null;
notes = NoteRepository(FakeNoteGateway());
pdfs = PdfSourceRepository(
FakePdfSourceGateway(),
AssetRepository(
FakeAssetGateway(),
FakeFileServerGateway(),
MemoryFileCache(),
FakeAssetSealing(),
),
);
});
tearDown(() async {
await pdfs.dispose();
});
PublishBloc bloc() => PublishBloc(
@ -47,10 +75,14 @@ void main() {
),
),
cards,
NoteRepository(FakeNoteGateway()),
notes,
LearningPathRepository(FakeLearningPathGateway()),
pdfs,
studysetId: 'japanese',
sync: () async => calls.add('sync'),
sync: () async {
calls.add('sync');
await onSync?.call();
},
);
/// Publishes through a started [bloc] and returns where it ends.
@ -100,6 +132,107 @@ void main() {
await publisher.close();
});
test('the material is confirmed: nothing ticked at first but what the '
'latest release held, and what is not ticked stays out, as does '
'what the sync brings unseen (ADR 0044, D9)', () async {
final particles = (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.empty()),
)).noteId;
final verbs = (await notes.create(
'kanji',
NoteContent(title: 'Verbs', body: RichContent.empty()),
)).noteId;
final book = await pdfs.add(
'japanese',
const PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: 'asset',
),
);
final readMe = await pdfs.add(
'kanji',
const PdfSourceContent(
title: 'Read me',
fileName: 'read-me.md',
assetId: 'other',
kind: FileKind.markdown,
),
);
publishing.released['japanese'] = {book};
final publisher = bloc()..add(const PublishEventStarted());
await publisher.stream.firstWhere((state) => state is PublishStateReady);
publisher.add(
const PublishEventMaterialAsked(subtree: ['japanese', 'kanji']),
);
final asked = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && state.material != null,
) as PublishStateReady;
final choice = asked.material!;
expect(choice.items, [
MaterialItem(
id: particles,
kind: MaterialKind.note,
title: 'Particles',
studysetId: 'japanese',
),
MaterialItem(
id: book,
kind: MaterialKind.pdf,
title: 'A book',
studysetId: 'japanese',
),
MaterialItem(
id: verbs,
kind: MaterialKind.note,
title: 'Verbs',
studysetId: 'kanji',
),
MaterialItem(
id: readMe,
kind: MaterialKind.file,
title: 'Read me',
studysetId: 'kanji',
),
]);
expect(choice.ticked, {book});
publisher.add(PublishEventMaterialChosen(choice.toggled(verbs)));
await publisher.stream.firstWhere(
(state) =>
state is PublishStateReady &&
(state.material?.ticked.contains(verbs) ?? false),
);
late String unseen;
onSync = () async => unseen = (await notes.create(
'japanese',
NoteContent(title: 'From the other device', body: RichContent.empty()),
)).noteId;
publisher.add(
const PublishEventRequested(
slug: 'japanese',
notes: '',
subtree: ['japanese', 'kanji'],
tagNames: null,
),
);
final ready = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && state.outcome != null,
) as PublishStateReady;
expect(ready.outcome, PublishOutcome.published);
expect(publishing.publishes.single.leftOut.toSet(), {
particles,
readMe,
unseen,
});
// Asked again for the next release.
expect(ready.material, isNull);
await publisher.close();
});
test('syncs before publishing: the instance builds the release from what '
'it holds', () async {
final publisher = bloc();

View file

@ -6,6 +6,10 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
@ -14,6 +18,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_pdf_source_gateway.dart';
import '../../support/fake_publish_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
@ -28,11 +34,15 @@ void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cards;
late FakePublishGateway publishing;
late FakeNoteGateway notes;
late FakePdfSourceGateway pdfs;
setUp(() {
studysets = FakeStudysetGateway();
cards = FakeCardGateway();
publishing = FakePublishGateway();
notes = FakeNoteGateway();
pdfs = FakePdfSourceGateway();
});
/// A studyset on this device before the app starts.
@ -78,6 +88,8 @@ void main() {
),
studysetGateway: studysets,
cardGateway: cards,
noteGateway: notes,
pdfGateway: pdfs,
profileGateway: FakePublicProfileGateway(profile: profile),
publishGateway: publishing,
),
@ -99,6 +111,19 @@ void main() {
}
}
/// Leads from the sheet's form to the step confirming the material.
Future<void> toMaterial(WidgetTester tester) async {
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
}
/// Publishes through the step confirming the material, by its button
/// [publish], with nothing more ticked.
Future<void> publishThrough(WidgetTester tester, String publish) async {
await toMaterial(tester);
await tester.tap(find.text(publish));
}
testWidgets('a studyset not published is published under a name made '
"from its title, at its author's handle", (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-SA-4.0');
@ -120,14 +145,15 @@ void main() {
find.widgetWithText(TextField, 'What changed'),
'The first lessons.',
);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(publishing.publishes.single, (
studysetId: 'japanese',
slug: 'japanese',
notes: 'The first lessons.',
));
final published = publishing.publishes.single;
expect(
(published.studysetId, published.slug, published.notes),
('japanese', 'japanese', 'The first lessons.'),
);
expect(published.leftOut, isEmpty);
expect(find.text('Published v1.'), findsOneWidget);
expect(find.textContaining('Published · v1'), findsOneWidget);
expect(find.text('halkyon.eu/@raisondetredev/japanese'), findsOneWidget);
@ -135,6 +161,7 @@ void main() {
find.text('Fixed since you first published: others refer to it.'),
findsOneWidget,
);
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -151,10 +178,11 @@ void main() {
expect(find.text('Description on Explore'), findsOneWidget);
expect(find.text('From N5 on.'), findsWidgets);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(publishing.descriptions.single?.plainText, 'From N5 on.');
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -201,6 +229,7 @@ void main() {
tester.widget<TextField>(find.widgetWithText(TextField, 'Name')).readOnly,
isTrue,
);
await toMaterial(tester);
expect(find.text('Publish v3'), findsOneWidget);
});
@ -238,6 +267,7 @@ void main() {
expect(find.text('Taken off Explore.'), findsOneWidget);
expect(find.text('Withdrawn · copies get no updates'), findsOneWidget);
expect(find.text('Take off Explore'), findsNothing);
await toMaterial(tester);
expect(find.text('Publish v2'), findsOneWidget);
});
@ -252,6 +282,7 @@ void main() {
findsOneWidget,
);
expect(find.textContaining('Publish v'), findsNothing);
expect(find.text('Continue'), findsNothing);
await tester.tap(find.text('Make a public profile'));
await tester.pumpAndSettle();
@ -268,7 +299,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
@ -288,7 +319,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
@ -349,7 +380,7 @@ void main() {
await showStudyset(tester, 'japanese');
await openSheet(tester);
await tester.tap(find.text('Publish v1'));
await publishThrough(tester, 'Publish v1');
await tester.pumpAndSettle();
expect(
find.text(
@ -399,6 +430,77 @@ void main() {
await tester.tap(find.text('Retry'));
await tester.pumpAndSettle();
await toMaterial(tester);
expect(find.text('Publish v1'), findsOneWidget);
});
testWidgets('the notes and files that go are confirmed first: none ticked '
'at first, each kind ticked whole, or everything but one, or nothing '
'but one (ADR 0044, D8 and D9)', (tester) async {
stored('japanese', 'Japanese', license: 'CC-BY-SA-4.0');
stored('kanji', 'Kanji', parent: 'japanese');
Future<String> note(String studyset, String title) async =>
(await notes.insertNote(
studysetId: studyset,
position: 'a0',
payload: encodeNotePayload(
NoteContent(title: title, body: RichContent.empty()),
),
)).noteId;
final particles = await note('japanese', 'Particles');
final radicals = await note('kanji', 'Radicals');
pdfs.receiveRemote(
id: 'book',
studysetId: 'japanese',
payload: encodePdfSourcePayload(
const PdfSourceContent(
title: 'A book',
fileName: 'book.pdf',
assetId: 'asset',
),
),
);
await showStudyset(tester, 'japanese');
await openSheet(tester);
await toMaterial(tester);
expect(find.text('Notes (2)'), findsOneWidget);
expect(find.text('PDFs (1)'), findsOneWidget);
expect(find.text('Other files (0)'), findsNothing);
// A topic's names where it is.
expect(
find.descendant(
of: find.byKey(ValueKey('material-$radicals')),
matching: find.text('Kanji'),
),
findsOneWidget,
);
expect(find.text('No notes or files go'), findsOneWidget);
await tester.tap(find.text('Notes (2)'));
await tester.pumpAndSettle();
expect(find.text('2 notes and files go'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('material-menu-book')));
await tester.pumpAndSettle();
await tester.tap(find.text('Nothing but this'));
await tester.pumpAndSettle();
expect(find.text('1 note or file goes'), findsOneWidget);
await tester.tap(find.byKey(ValueKey('material-menu-$radicals')));
await tester.pumpAndSettle();
await tester.tap(find.text('Everything but this'));
await tester.pumpAndSettle();
await tester.tap(find.text('Publish v1'));
await tester.pumpAndSettle();
expect(publishing.publishes.single.leftOut, [radicals]);
expect(particles, isNot(radicals));
// The next release starts from what the latest held.
publishing.released['japanese'] = {particles, 'book'};
await toMaterial(tester);
expect(find.text('2 notes and files go'), findsOneWidget);
await tester.tap(find.text('Back'));
await tester.pumpAndSettle();
expect(find.text('Continue'), findsOneWidget);
});
}

View file

@ -0,0 +1,68 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/release_material.dart';
/// The material confirmed before publishing (ADR 0044, D8 and D9).
void main() {
const items = [
MaterialItem(
id: 'n1',
kind: MaterialKind.note,
title: 'Particles',
studysetId: 's',
),
MaterialItem(
id: 'n2',
kind: MaterialKind.note,
title: 'Verbs',
studysetId: 's',
),
MaterialItem(
id: 'p1',
kind: MaterialKind.pdf,
title: 'Book',
studysetId: 's',
),
MaterialItem(
id: 'f1',
kind: MaterialKind.file,
title: 'Read me',
studysetId: 's',
),
];
test('nothing is ticked at first but what the latest release held', () {
expect(MaterialChoice.initial(items, const {}).ticked, isEmpty);
expect(
MaterialChoice.initial(items, const {'p1', 'gone'}).ticked,
{'p1'},
);
expect(MaterialChoice.initial(items, const {}).leftOut, [
'n1',
'n2',
'p1',
'f1',
]);
});
test('a kind is ticked whole, or none of it, and says how much is', () {
final none = MaterialChoice.initial(items, const {});
expect(none.allOf(MaterialKind.note), isFalse);
final notes = none.withKind(MaterialKind.note, ticked: true);
expect(notes.ticked, {'n1', 'n2'});
expect(notes.allOf(MaterialKind.note), isTrue);
expect(notes.allOf(MaterialKind.pdf), isFalse);
expect(notes.toggled('n1').allOf(MaterialKind.note), isNull);
expect(
notes.withKind(MaterialKind.note, ticked: false).ticked,
isEmpty,
);
});
test('everything but this, and nothing but this, across the kinds', () {
final none = MaterialChoice.initial(items, const {});
expect(none.allBut('n2').ticked, {'n1', 'p1', 'f1'});
expect(none.allBut('n2').leftOut, ['n2']);
expect(none.allBut('n2').onlyThis('f1').ticked, {'f1'});
expect(none.toggled('p1').toggled('p1').ticked, isEmpty);
});
}

View file

@ -17,8 +17,20 @@ class FakePublishGateway({
/// Refusals to answer the next publishes with, the first first.
final refusals = <PublishRefused>[];
/// What each publish was asked: the studyset, name and notes.
final publishes = <({String studysetId, String slug, String notes})>[];
/// What each publish was asked: the studyset, name, notes and what it
/// left out.
final publishes =
<
({
String studysetId,
String slug,
String notes,
List<String> leftOut,
})
>[];
/// The material of each studyset's latest release, by id.
final released = <String, Set<String>>{};
/// The description for Explore each publish sent, in order.
final descriptions = <RichContent?>[];
@ -60,11 +72,20 @@ class FakePublishGateway({
);
}
@override
Future<Set<String>> releasedMaterial(String studysetId) async {
if (fail case final error?) {
throw error;
}
return released[studysetId] ?? const {};
}
@override
Future<Publication> publish(
String studysetId, {
required String slug,
required String notes,
required List<String> leftOut,
RichContent? description,
Map<String, String>? tagNames,
}) async {
@ -72,7 +93,12 @@ class FakePublishGateway({
if (fail case final error?) {
throw error;
}
publishes.add((studysetId: studysetId, slug: slug, notes: notes));
publishes.add((
studysetId: studysetId,
slug: slug,
notes: notes,
leftOut: leftOut,
));
descriptions.add(description);
if (refusals.isNotEmpty) {
throw refusals.removeAt(0);