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:
parent
d09625d43e
commit
72cac92365
11 changed files with 310 additions and 27 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue