feat(learn): a Viewer suggests new flashcards and notes

ADR 0044's step 6b (D48) in the app: where an Editor has Add material, a
Viewer of a shared studyset has Suggest material, offering a flashcard
or a note. The composer and the note editor open suggesting from the
start, saying that what is suggested joins the studyset once its owner
or an editor takes it in; Send suggestion asks why, and Cancel or
sending closes it. Nothing is written into the studyset: the fields go
into the suggestion alone, which goes into the Viewer's own space as
the changes they suggest do.

Whats-New: In a studyset shared with you as a viewer, Suggest material proposes a new flashcard or note to its owner and editors.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du über „Material vorschlagen“ neue Karteikarten und Notizen vor.

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 21:29:05 +01:00
commit 72cac92365
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 310 additions and 27 deletions

View file

@ -2318,5 +2318,9 @@
"suggestInSpace": "Änderung vorschlagen", "suggestInSpace": "Änderung vorschlagen",
"suggestInSpaceIntro": "Besitzer:in und Bearbeiter:innen dieses Lernsets sehen es neben ihrer Fassung und übernehmen es oder nicht. Alle darin lesen es, mit deinem öffentlichen Profil.", "suggestInSpaceIntro": "Besitzer:in und Bearbeiter:innen dieses Lernsets sehen es neben ihrer Fassung und übernehmen es oder nicht. Alle darin lesen es, mit deinem öffentlichen Profil.",
"suggestingNote": "Du schlägst eine Änderung vor: Nichts ändert sich, bis Besitzer:in oder Bearbeiter:innen sie übernehmen.", "suggestingNote": "Du schlägst eine Änderung vor: Nichts ändert sich, bis Besitzer:in oder Bearbeiter:innen sie übernehmen.",
"suggestSend": "Vorschlag senden" "suggestSend": "Vorschlag senden",
"studysetSuggestMaterial": "Material vorschlagen",
"suggestNewFlashcard": "Karteikarte vorschlagen",
"suggestNewNote": "Notiz vorschlagen",
"suggestingNew": "Du schlägst etwas Neues vor: Es kommt ins Lernset, sobald Besitzer:in oder Bearbeiter:innen es annehmen."
} }

View file

@ -2966,5 +2966,13 @@
"suggestingNote": "You're suggesting a change: nothing changes until the owner or an editor takes it in.", "suggestingNote": "You're suggesting a change: nothing changes until the owner or an editor takes it in.",
"@suggestingNote": {"description": "The line above a card or note a Viewer changes as a suggestion"}, "@suggestingNote": {"description": "The line above a card or note a Viewer changes as a suggestion"},
"suggestSend": "Send suggestion", "suggestSend": "Send suggestion",
"@suggestSend": {"description": "Sends a Viewer's changed card or note as a suggestion"} "@suggestSend": {"description": "Sends a Viewer's changed card or note as a suggestion"},
"studysetSuggestMaterial": "Suggest material",
"@studysetSuggestMaterial": {"description": "A Viewer's menu in a shared studyset, in place of Add material (Halkyon's ADR 0044, D44): suggesting new flashcards or notes"},
"suggestNewFlashcard": "Suggest a flashcard",
"@suggestNewFlashcard": {"description": "Suggest material's item: opens the composer, suggesting a new flashcard"},
"suggestNewNote": "Suggest a note",
"@suggestNewNote": {"description": "Suggest material's item: opens the note editor, suggesting a new note"},
"suggestingNew": "You're suggesting something new: it joins the studyset once the owner or an editor takes it in.",
"@suggestingNew": {"description": "Above a flashcard or note a Viewer suggests anew, with Send suggestion and Cancel"}
} }

View file

@ -13693,6 +13693,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Send suggestion'** /// **'Send suggestion'**
String get suggestSend; String get suggestSend;
/// A Viewer's menu in a shared studyset, in place of Add material (Halkyon's ADR 0044, D44): suggesting new flashcards or notes
///
/// In en, this message translates to:
/// **'Suggest material'**
String get studysetSuggestMaterial;
/// Suggest material's item: opens the composer, suggesting a new flashcard
///
/// In en, this message translates to:
/// **'Suggest a flashcard'**
String get suggestNewFlashcard;
/// Suggest material's item: opens the note editor, suggesting a new note
///
/// In en, this message translates to:
/// **'Suggest a note'**
String get suggestNewNote;
/// Above a flashcard or note a Viewer suggests anew, with Send suggestion and Cancel
///
/// In en, this message translates to:
/// **'You\'re suggesting something new: it joins the studyset once the owner or an editor takes it in.'**
String get suggestingNew;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -8747,4 +8747,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get suggestSend => 'Vorschlag senden'; String get suggestSend => 'Vorschlag senden';
@override
String get studysetSuggestMaterial => 'Material vorschlagen';
@override
String get suggestNewFlashcard => 'Karteikarte vorschlagen';
@override
String get suggestNewNote => 'Notiz vorschlagen';
@override
String get suggestingNew =>
'Du schlägst etwas Neues vor: Es kommt ins Lernset, sobald Besitzer:in oder Bearbeiter:innen es annehmen.';
} }

View file

@ -8668,6 +8668,19 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get suggestSend => 'Send suggestion'; String get suggestSend => 'Send suggestion';
@override
String get studysetSuggestMaterial => 'Suggest material';
@override
String get suggestNewFlashcard => 'Suggest a flashcard';
@override
String get suggestNewNote => 'Suggest a note';
@override
String get suggestingNew =>
'You\'re suggesting something new: it joins the studyset once the owner or an editor takes it in.';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -169,7 +169,13 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
return; return;
} }
// A studyset without cards opens on a new one, once. // A studyset without cards opens on a new one, once.
if (!_seeded && state.items.isEmpty) { // A new card to start with where there are none, and for a
// Viewer suggesting one (Halkyon's ADR 0044, D48), who has no
// button to add one.
if (!_seeded &&
(state.items.isEmpty ||
(widget.initialCardId == null &&
StudysetAccess.viewOnlyNow(context)))) {
_seeded = true; _seeded = true;
_addDraft(); _addDraft();
return; return;

View file

@ -160,8 +160,12 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
/// (Halkyon's ADR 0044, D44): it goes nowhere until sent as a suggestion. /// (Halkyon's ADR 0044, D44): it goes nowhere until sent as a suggestion.
CardContent? _suggested; CardContent? _suggested;
/// Whether a Viewer is suggesting a change. /// Whether a Viewer is suggesting a change; a card a Viewer writes anew
var _suggesting = false; /// is a suggestion from the start (Halkyon's ADR 0044, D48).
late var _suggesting = _isNew && StudysetAccess.viewOnlyNow(context);
/// Whether the card is a new one, not written yet.
bool get _isNew => widget.draft.cardId == null;
/// The card as this pane shows it. /// The card as this pane shows it.
CardContent get _current => _suggested ?? _sent; CardContent get _current => _suggested ?? _sent;
@ -298,12 +302,19 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
_change(_current.withField(CardField.explanation, RichContent.empty())); _change(_current.withField(CardField.explanation, RichContent.empty()));
} }
/// Leaves suggesting, the fields as the card is. /// Leaves suggesting, the fields as the card is; a new card suggested,
void _stopSuggesting() => setState(() { /// or not after all, leaves the composer.
_suggesting = false; void _stopSuggesting() {
_suggested = null; if (_isNew) {
_revision++; Navigator.of(context).pop();
}); return;
}
setState(() {
_suggesting = false;
_suggested = null;
_revision++;
});
}
/// Sends the card as the Viewer would have it as a suggestion, asking /// Sends the card as the Viewer would have it as a suggestion, asking
/// why; leaves suggesting once it went. /// why; leaves suggesting once it went.
@ -363,6 +374,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
if (access.viewOnly) if (access.viewOnly)
SuggestBar( SuggestBar(
suggesting: _suggesting, suggesting: _suggesting,
isNew: _isNew,
onStart: () => setState(() => _suggesting = true), onStart: () => setState(() => _suggesting = true),
onCancel: _stopSuggesting, onCancel: _stopSuggesting,
onSend: () => unawaited(_sendSuggestion()), onSend: () => unawaited(_sendSuggestion()),

View file

@ -122,12 +122,14 @@ Future<bool> suggestInSpace(
/// Above what a Viewer opens in a shared studyset (Halkyon's ADR 0044, /// Above what a Viewer opens in a shared studyset (Halkyon's ADR 0044,
/// D35, D44): that it is theirs to view and study, with Suggest a change; /// D35, D44): that it is theirs to view and study, with Suggest a change;
/// while [suggesting], that nothing changes until it is taken in, with /// while [suggesting], that nothing changes until it is taken in, with
/// Cancel and Send suggestion. /// Cancel and Send suggestion; for something [isNew], that it joins the
/// studyset once taken in.
class const SuggestBar({ class const SuggestBar({
required final bool suggesting, required final bool suggesting,
required final VoidCallback onStart, required final VoidCallback onStart,
required final VoidCallback onCancel, required final VoidCallback onCancel,
required final VoidCallback onSend, required final VoidCallback onSend,
final bool isNew = false,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -157,7 +159,7 @@ class const SuggestBar({
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Text(
l10n.suggestingNote, isNew ? l10n.suggestingNew : l10n.suggestingNote,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),

View file

@ -144,22 +144,33 @@ class const _Note({
class _NoteState() extends State<_Note> { class _NoteState() extends State<_Note> {
/// Whether a Viewer is suggesting a change, and the note as they would /// Whether a Viewer is suggesting a change, and the note as they would
/// have it, which goes nowhere until sent. /// have it, which goes nowhere until sent. A note a Viewer writes anew
var _suggesting = false; /// is a suggestion from the start (Halkyon's ADR 0044, D48).
late var _suggesting = _isNew && StudysetAccess.viewOnlyNow(context);
NoteContent? _suggested; NoteContent? _suggested;
/// Whether the note is a new one, not written yet.
bool get _isNew => state.noteId == null;
/// Shows the editor anew as suggesting starts or stops. /// Shows the editor anew as suggesting starts or stops.
var _round = 0; var _round = 0;
NoteEditorState get state => widget.state; NoteEditorState get state => widget.state;
TextEditingController get title => widget.title; TextEditingController get title => widget.title;
void _stopSuggesting() => setState(() { void _stopSuggesting() {
_suggesting = false; // A new note suggested, or not after all, leaves nothing to show.
_suggested = null; if (_isNew) {
title.text = state.content.title; Navigator.of(context).pop();
_round++; return;
}); }
setState(() {
_suggesting = false;
_suggested = null;
title.text = state.content.title;
_round++;
});
}
Future<void> _sendSuggestion() async { Future<void> _sendSuggestion() async {
final content = _suggested ?? state.content; final content = _suggested ?? state.content;
@ -205,6 +216,7 @@ class _NoteState() extends State<_Note> {
if (viewOnly) if (viewOnly)
SuggestBar( SuggestBar(
suggesting: _suggesting, suggesting: _suggesting,
isNew: _isNew,
onStart: () => setState(() { onStart: () => setState(() {
_suggesting = true; _suggesting = true;
_round++; _round++;

View file

@ -450,8 +450,39 @@ class _StudysetViewState() extends State<_StudysetView> {
child: _StudyButton(studyset: studyset, inAppBar: true), child: _StudyButton(studyset: studyset, inAppBar: true),
), ),
), ),
// A Viewer adds nothing (Halkyon's ADR 0044, D30). // A Viewer adds nothing (Halkyon's ADR 0044, D30), and
if (!studyset.viewOnly) // suggests material instead (D44, D48).
if (studyset.viewOnly)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: _SuggestMaterial(
studysetId: studyset.id,
controller: _addMaterial,
builder: (context, menu) => roomy
? FilledButton.tonalIcon(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
),
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.pencilLine),
label: Text(
AppLocalizations.of(
context,
).studysetSuggestMaterial,
),
)
: IconButton.filledTonal(
tooltip: AppLocalizations.of(
context,
).studysetSuggestMaterial,
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
icon: const Icon(LucideIcons.pencilLine),
),
),
)
else
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: _AddMaterial( child: _AddMaterial(
@ -1129,6 +1160,37 @@ class const _StudyPill({
/// language where the instance has one. What is added shows in its own tab ([ContentTabCubit]): what is /// language where the instance has one. What is added shows in its own tab ([ContentTabCubit]): what is
/// written there once the person comes back, what is uploaded once it is /// written there once the person comes back, what is uploaded once it is
/// in. Imports are under the options. /// in. Imports are under the options.
/// What a Viewer suggests anew within a shared studyset (Halkyon's ADR
/// 0044, D48): a flashcard or a note, written in the composer or the note
/// editor as a suggestion from the start.
class const _SuggestMaterial({
required final String studysetId,
required final MenuController controller,
required final Widget Function(BuildContext context, MenuController menu)
builder,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return MenuAnchor(
controller: controller,
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.plus),
onPressed: () => context.push(RoutePaths.newCard(studysetId)),
child: Text(l10n.suggestNewFlashcard),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.notebookPen),
onPressed: () => context.push(RoutePaths.newNote(studysetId)),
child: Text(l10n.suggestNewNote),
),
],
builder: (context, menu, _) => builder(context, menu),
);
}
}
class const _AddMaterial({ class const _AddMaterial({
required final String studysetId, required final String studysetId,
required final MenuController controller, required final MenuController controller,
@ -1409,8 +1471,34 @@ class const _StudyBar({
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// A Viewer adds nothing (Halkyon's ADR 0044, D30). // A Viewer adds nothing (Halkyon's ADR 0044, D30), and
if (!studyset.viewOnly) ...[ // suggests material instead (D44, D48).
if (studyset.viewOnly) ...[
_SuggestMaterial(
studysetId: studyset.id,
controller: addMaterial,
builder: (context, menu) => Tooltip(
message: l10n.studysetSuggestMaterial,
child: Material(
elevation: 6,
color: scheme.secondaryContainer,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => menu.isOpen ? menu.close() : menu.open(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
LucideIcons.pencilLine,
color: scheme.onSecondaryContainer,
),
),
),
),
),
),
const SizedBox(width: 12),
] else ...[
_AddMaterial( _AddMaterial(
studysetId: studyset.id, studysetId: studyset.id,
controller: addMaterial, controller: addMaterial,

View file

@ -238,6 +238,105 @@ void main() {
); );
}); });
testWidgets("a Viewer's studyset offers to suggest a flashcard or a note, "
'never to add one', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('Add material'), findsNothing);
await tester.tap(find.text('Suggest material'));
await tester.pumpAndSettle();
expect(find.text('Suggest a flashcard'), findsOneWidget);
expect(find.text('Suggest a note'), findsOneWidget);
expect(find.text('Create flashcards'), findsNothing);
expect(find.text('Create a note'), findsNothing);
});
testWidgets('an Editor adds material, suggesting none', (tester) async {
shared(viewOnly: false);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('Add material'), findsOneWidget);
expect(find.text('Suggest material'), findsNothing);
});
/// Opens what a Viewer suggests anew from the studyset's menu: [item],
/// "Suggest a flashcard" or "Suggest a note".
Future<void> suggestNew(WidgetTester tester, String item) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Suggest material'));
await tester.pumpAndSettle();
await tester.tap(find.text(item));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
testWidgets("a Viewer's new note goes as a suggestion for the studyset, "
'which it joins only once taken in', (tester) async {
await suggestNew(tester, 'Suggest a note');
// Suggesting from the start, with nothing to change.
expect(
find.textContaining('suggesting something new', findRichText: true),
findsOneWidget,
);
expect(find.text('Suggest a change'), findsNothing);
await tester.enterText(find.byType(TextField).first, 'Particles が');
await tester.pump();
await send(tester, 'The other particle.');
final thread = written.threads.single;
expect(thread.space, 'space-1');
expect(thread.json['target'], {
'kind': 'newNote',
'item': 'japanese',
'version': 0,
});
expect(
(thread.json['proposed']! as Map<String, Object?>)['title'],
'Particles が',
);
// Nothing added; back at the studyset.
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
expect(await notes.notesOf('japanese'), hasLength(1));
expect(find.text('Suggest material'), findsOneWidget);
});
testWidgets("a Viewer's new flashcard goes as a suggestion; cancelled, "
'nothing goes and nothing is added', (tester) async {
await suggestNew(tester, 'Suggest a flashcard');
expect(
find.textContaining('suggesting something new', findRichText: true),
findsOneWidget,
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(written.threads, isEmpty);
expect(find.text('Suggest material'), findsOneWidget);
await tester.tap(find.text('Suggest material'));
await tester.pumpAndSettle();
await tester.tap(find.text('Suggest a flashcard'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await send(tester, 'A card on Kyoto.');
expect(written.threads.single.json['target'], {
'kind': 'newCard',
'item': 'japanese',
'version': 0,
});
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
expect(await cards.cardsIn(['japanese']), hasLength(1));
expect(find.text('Suggest material'), findsOneWidget);
});
testWidgets("a shared studyset's menu offers its suggestions", ( testWidgets("a shared studyset's menu offers its suggestions", (
tester, tester,
) async { ) async {

View file

@ -381,8 +381,10 @@ member reads everything in the space.
where it names the studyset's own files and brings none; a Viewer's where it names the studyset's own files and brings none; a Viewer's
card and note offer Suggest a change, and a shared studyset's menu its card and note offer Suggest a change, and a shared studyset's menu its
Suggestions, where its Owner and Editors accept or decline. New cards Suggestions, where its Owner and Editors accept or decline. New cards
and notes are suggested through the repository (the agent, step 6c); and notes are suggested through the repository (the agent, step 6c),
the app offers a Viewer no button for them yet. and in the app from a Viewer's Suggest material, in place of Add
material: the composer or note editor opens suggesting from the
start, Cancel or Send closing it, nothing added.
c. The agent itself, its CLI and MCP server, on a pure-Dart core (D7, D41, D49 to c. The agent itself, its CLI and MCP server, on a pure-Dart core (D7, D41, D49 to
D52). D52).