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",
"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.",
"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": {"description": "The line above a card or note a Viewer changes as a 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:
/// **'Send suggestion'**
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

View file

@ -8747,4 +8747,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
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`).

View file

@ -169,7 +169,13 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
return;
}
// 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;
_addDraft();
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.
CardContent? _suggested;
/// Whether a Viewer is suggesting a change.
var _suggesting = false;
/// Whether a Viewer is suggesting a change; a card a Viewer writes anew
/// 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.
CardContent get _current => _suggested ?? _sent;
@ -298,12 +302,19 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
_change(_current.withField(CardField.explanation, RichContent.empty()));
}
/// Leaves suggesting, the fields as the card is.
void _stopSuggesting() => setState(() {
_suggesting = false;
_suggested = null;
_revision++;
});
/// Leaves suggesting, the fields as the card is; a new card suggested,
/// or not after all, leaves the composer.
void _stopSuggesting() {
if (_isNew) {
Navigator.of(context).pop();
return;
}
setState(() {
_suggesting = false;
_suggested = null;
_revision++;
});
}
/// Sends the card as the Viewer would have it as a suggestion, asking
/// why; leaves suggesting once it went.
@ -363,6 +374,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
if (access.viewOnly)
SuggestBar(
suggesting: _suggesting,
isNew: _isNew,
onStart: () => setState(() => _suggesting = true),
onCancel: _stopSuggesting,
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,
/// 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
/// Cancel and Send suggestion.
/// Cancel and Send suggestion; for something [isNew], that it joins the
/// studyset once taken in.
class const SuggestBar({
required final bool suggesting,
required final VoidCallback onStart,
required final VoidCallback onCancel,
required final VoidCallback onSend,
final bool isNew = false,
super.key,
}) extends StatelessWidget {
@override
@ -157,7 +159,7 @@ class const SuggestBar({
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.suggestingNote,
isNew ? l10n.suggestingNew : l10n.suggestingNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),

View file

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

View file

@ -450,8 +450,39 @@ class _StudysetViewState() extends State<_StudysetView> {
child: _StudyButton(studyset: studyset, inAppBar: true),
),
),
// A Viewer adds nothing (Halkyon's ADR 0044, D30).
if (!studyset.viewOnly)
// A Viewer adds nothing (Halkyon's ADR 0044, D30), and
// 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: const EdgeInsets.symmetric(horizontal: 8),
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
/// written there once the person comes back, what is uploaded once it is
/// 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({
required final String studysetId,
required final MenuController controller,
@ -1409,8 +1471,34 @@ class const _StudyBar({
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// A Viewer adds nothing (Halkyon's ADR 0044, D30).
if (!studyset.viewOnly) ...[
// A Viewer adds nothing (Halkyon's ADR 0044, D30), and
// 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(
studysetId: studyset.id,
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", (
tester,
) 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
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
and notes are suggested through the repository (the agent, step 6c);
the app offers a Viewer no button for them yet.
and notes are suggested through the repository (the agent, step 6c),
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
D52).