feat(studyset): every menu of a studyset offers all of its options

The library's card offered six options, a topic three, the studyset's
own screen all of them. Each now offers the same, from one list: edit,
move, study options, import and export content, reset progress, archive
or unarchive, delete, more info, and for a copy reporting to its author
and its reports and suggestions. Importing runs where the studyset's
blocs are, so from the library or a topic it opens the studyset's
screen, which asks what to import as it opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 23:34:26 +02:00
commit b7e5b695b9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 460 additions and 409 deletions

View file

@ -86,7 +86,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
return MaterialPage<void>(
key: ValueKey('studyset-$id'),
name: RoutePaths.studyset(id),
child: _studysetScreen(context, id),
child: _studysetScreen(
context,
id,
importOnOpen:
state.uri.queryParameters['import'] == 'content',
),
);
},
),
@ -314,10 +319,15 @@ Widget _stepEditor(
);
}
Widget _studysetScreen(BuildContext context, String id) => _inStudyset(
Widget _studysetScreen(
BuildContext context,
String id, {
bool importOnOpen = false,
}) => _inStudyset(
context,
id,
(studyset, all) => StudysetScreen(studyset: studyset, all: all),
(studyset, all) =>
StudysetScreen(studyset: studyset, all: all, importOnOpen: importOnOpen),
wayBack: (
label: AppLocalizations.of(context).studysetBackToLibrary,
location: RoutePaths.library,

View file

@ -31,6 +31,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyse
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -512,7 +513,6 @@ class _GridState() extends State<_Grid> {
}
Widget _card(BuildContext context, Studyset studyset) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>();
return StudysetCard(
key: ValueKey(studyset.id),
@ -533,50 +533,28 @@ class _GridState() extends State<_Grid> {
bloc.add(const StudysetListEventProgressRefreshRequested());
},
onToggleStar: () => bloc.add(StudysetListEventStarToggled(studyset)),
onEdit: () async {
final content = await showStudysetSheet(
context,
initial: studyset.content,
);
if (content != null && content != studyset.content) {
bloc.add(StudysetListEventContentChanged(studyset.id, content));
}
},
// The grid's context, as for deleting: it holds the list's bloc and
// outlives the card's menu.
onMove: () async {
final all = await PersonScope.of(this.context).studysets.all();
if (mounted) {
await moveStudyset(this.context, studyset, all);
}
},
onStudyOptions: () => changeStudyOptions(this.context, studyset),
onImport: () => importFromAnki(this.context, studysetId: studyset.id),
// Archiving is tidying up, not destroying, so it doesn't ask first:
// it offers to undo.
onArchive: () {
bloc.add(StudysetListEventArchiveRequested(studyset.id));
showUndoSnackBar(
context,
message: l10n.libraryStudysetArchived,
onUndo: () =>
bloc.add(StudysetListEventUnarchiveRequested(studyset.id)),
);
},
onUnarchive: () {
bloc.add(StudysetListEventUnarchiveRequested(studyset.id));
showUndoSnackBar(
context,
message: l10n.libraryStudysetUnarchived,
onUndo: () =>
bloc.add(StudysetListEventArchiveRequested(studyset.id)),
);
},
// The grid's context, not the card's: the card is about to go.
onDelete: () => deleteStudysetWithUndo(this.context, studyset),
// The grid's context, not the card's: it holds the list's bloc and
// outlives the card's menu, and the card may be about to go.
onOption: (option) => unawaited(
doStudysetOption(
this.context,
option,
studyset,
onImport: () => unawaited(_openToImport(studyset)),
),
),
);
}
/// Opens [studyset]'s screen, asking what to import into it: its imports
/// run there.
Future<void> _openToImport(Studyset studyset) async {
final all = await PersonScope.of(context).studysets.all();
if (mounted) {
openStudyset(context, studyset.id, all, importContent: true);
}
}
Widget _deletedCard(BuildContext context, Studyset studyset) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>();

View file

@ -1,16 +1,290 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_move_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
// What a studyset's options do the same from the library's card and from the
// studyset's own screen, through the StudysetListBloc above them.
// What a studyset's options are and do, the same wherever a menu of it
// opens (decided 2026-10-12): its card in the library, its own screen, a
// topic beside it; through the StudysetListBloc above them.
/// One of a studyset's options.
enum StudysetOption() {
edit,
move,
studyOptions,
importContent,
exportContent,
reset,
archive,
unarchive,
delete,
info,
reportToAuthor,
feedback,
}
/// An option as its menus show it: what it is, its icon and its name, and
/// whether it destroys (deleting), in the error colour.
typedef StudysetOptionEntry = ({
StudysetOption option,
IconData icon,
String label,
bool destroys,
});
/// [studyset]'s options, in order: edit, move, its study options, import
/// and export content, reset its progress, archive or unarchive, delete,
/// what there is to know about it; for a copy of a published studyset,
/// reporting to its author and its reports and suggestions.
List<StudysetOptionEntry> studysetOptions(
AppLocalizations l10n,
Studyset studyset,
) {
StudysetOptionEntry entry(
StudysetOption option,
IconData icon,
String label, {
bool destroys = false,
}) => (option: option, icon: icon, label: label, destroys: destroys);
return [
entry(StudysetOption.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
entry(StudysetOption.move, LucideIcons.folderInput, l10n.studysetMenuMove),
entry(
StudysetOption.studyOptions,
LucideIcons.slidersHorizontal,
l10n.studyOptionsTitle,
),
entry(
StudysetOption.importContent,
LucideIcons.fileInput,
l10n.studysetImportContent,
),
entry(
StudysetOption.exportContent,
LucideIcons.fileOutput,
l10n.studysetMenuExport,
),
entry(StudysetOption.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
if (studyset.isArchived)
entry(
StudysetOption.unarchive,
LucideIcons.archiveRestore,
l10n.studysetMenuUnarchive,
)
else
entry(
StudysetOption.archive,
LucideIcons.archive,
l10n.studysetMenuArchive,
),
entry(
StudysetOption.delete,
LucideIcons.trash2,
l10n.studysetMenuDelete,
destroys: true,
),
entry(StudysetOption.info, LucideIcons.info, l10n.moreInfo),
if (fromRelease(studyset.content.published)) ...[
entry(
StudysetOption.reportToAuthor,
LucideIcons.flag,
l10n.reportToAuthor,
),
entry(
StudysetOption.feedback,
LucideIcons.messagesSquare,
l10n.studysetFeedback,
),
],
];
}
/// [studyset]'s options as a ⋮ (or [icon]) button's menu, each chosen
/// handed to [onSelected].
class const StudysetOptionsButton({
required final Studyset studyset,
required final ValueChanged<StudysetOption> onSelected,
final Widget icon = const Icon(LucideIcons.ellipsisVertical),
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error;
return PopupMenuButton<StudysetOption>(
icon: icon,
tooltip: l10n.studysetOptions,
onSelected: onSelected,
itemBuilder: (context) => [
for (final entry in studysetOptions(l10n, studyset))
PopupMenuItem(
value: entry.option,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(entry.icon, color: entry.destroys ? error : null),
title: Text(
entry.label,
style: TextStyle(color: entry.destroys ? error : null),
),
),
),
],
);
}
}
/// Does [option] for [studyset], with [all] the person's studysets (read
/// when not given). [onImport] imports content into it, as where the menu
/// is can; [onDeleted] runs once it is deleted, as to close its screen.
Future<void> doStudysetOption(
BuildContext context,
StudysetOption option,
Studyset studyset, {
required VoidCallback onImport,
List<Studyset>? all,
VoidCallback? onDeleted,
}) async {
final l10n = AppLocalizations.of(context);
Future<List<Studyset>> everything() async =>
all ?? await PersonScope.of(context).studysets.all();
switch (option) {
case StudysetOption.edit:
final studysets = await everything();
if (context.mounted) {
await editStudyset(context, studyset, studysets);
}
case StudysetOption.move:
final studysets = await everything();
if (context.mounted) {
await moveStudyset(context, studyset, studysets);
}
case StudysetOption.studyOptions:
await changeStudyOptions(context, studyset);
case StudysetOption.importContent:
onImport();
case StudysetOption.exportContent:
await exportStudyset(context, studyset);
case StudysetOption.reset:
await resetStudysetProgress(context, studyset);
case StudysetOption.archive || StudysetOption.unarchive:
archiveStudyset(
context,
studyset,
archive: option == StudysetOption.archive,
);
case StudysetOption.delete:
deleteStudysetWithUndo(context, studyset, onDeleted: onDeleted);
case StudysetOption.info:
await showStudysetInfo(context, studyset);
case StudysetOption.reportToAuthor:
if (studyset.content.published case final origin?) {
await reportStudysetToAuthor(context, origin);
}
case StudysetOption.feedback:
if (studyset.content.published case final origin?) {
await showStudysetFeedback(
context,
publicId: origin.publication,
title: l10n.studysetFeedback,
);
}
}
}
/// Edits [studyset]'s title, colour and the like, with what it inherits
/// from the studyset it sits in among [all].
Future<void> editStudyset(
BuildContext context,
Studyset studyset,
List<Studyset> all,
) async {
final bloc = context.read<StudysetListBloc>();
final parentId = studyset.parentId;
final content = await showStudysetSheet(
context,
initial: studyset.content,
insideAnother: parentId != null,
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
inheritedLanguages: parentId == null
? (front: null, back: null)
: languagesOf(parentId, all),
);
if (content != null && content != studyset.content) {
bloc.add(StudysetListEventContentChanged(studyset.id, content));
}
}
/// Archives [studyset], or takes it out of the archive, and offers to undo
/// that: archiving is tidying up, not destroying, so it doesn't ask first.
void archiveStudyset(
BuildContext context,
Studyset studyset, {
required bool archive,
}) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>()
..add(
archive
? StudysetListEventArchiveRequested(studyset.id)
: StudysetListEventUnarchiveRequested(studyset.id),
);
showUndoSnackBar(
context,
message: archive
? l10n.libraryStudysetArchived
: l10n.libraryStudysetUnarchived,
onUndo: () => bloc.add(
archive
? StudysetListEventUnarchiveRequested(studyset.id)
: StudysetListEventArchiveRequested(studyset.id),
),
);
}
/// Reports what is wrong with the studyset [origin] names, a copy's, to
/// the author of the published one.
Future<void> reportStudysetToAuthor(
BuildContext context,
PublishedRef origin,
) async {
if (!canWriteToAuthors(context)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).feedbackNeedsIdentity),
),
);
return;
}
if (await showReportSheet(context, authorOnly: true)
case (:final report, toAuthor: _) when context.mounted) {
await sendReportToAuthor(
context,
origin,
FeedbackTargetKind.studyset,
report,
);
}
}
/// Asks where [studyset] goes among [all] the person's studysets, moves it
/// there and says where it went.
@ -117,7 +391,7 @@ Future<void> resetStudysetProgress(
}
final chosen = await showDialog<({bool cards, bool pdfs, bool paths})>(
context: context,
builder: (_) => _ResetDialog(parts: parts),
builder: (dialogContext) => _ResetDialog(parts: parts),
);
if (chosen == null || !context.mounted) {
return;

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -48,26 +49,8 @@ class const StudysetCard({
/// Stars it, or takes its star away.
required final VoidCallback onToggleStar,
/// Edits its title, colour and the like.
required final VoidCallback onEdit,
/// Moves it into another studyset or to the top level.
required final VoidCallback onMove,
/// Changes how it is studied.
required final VoidCallback onStudyOptions,
/// Imports an Anki deck into it.
required final VoidCallback onImport,
/// Archives it.
required final VoidCallback onArchive,
/// Takes it out of the archive.
required final VoidCallback onUnarchive,
/// Deletes it.
required final VoidCallback onDelete,
/// Does one of its options, as every menu of a studyset has them.
required final ValueChanged<StudysetOption> onOption,
super.key,
}) extends StatefulWidget {
@override
@ -146,15 +129,9 @@ class _StudysetCardState() extends State<StudysetCard> {
onShare: widget.onShare,
starred: studyset.isStarred,
starVisible: studyset.isStarred || _hovering,
archived: archived,
onToggleStar: widget.onToggleStar,
onEdit: widget.onEdit,
onMove: widget.onMove,
onStudyOptions: widget.onStudyOptions,
onImport: widget.onImport,
onArchive: widget.onArchive,
onUnarchive: widget.onUnarchive,
onDelete: widget.onDelete,
studyset: studyset,
onOption: widget.onOption,
),
],
),
@ -322,16 +299,6 @@ class const _Paused() extends StatelessWidget {
);
}
enum _Option() {
edit,
move,
studyOptions,
import,
archive,
unarchive,
delete,
}
/// Who made it, the star, whether it is public, and the options.
class const _BottomBar({
required final String by,
@ -339,15 +306,9 @@ class const _BottomBar({
required final VoidCallback onShare,
required final bool starred,
required final bool starVisible,
required final bool archived,
required final VoidCallback onToggleStar,
required final VoidCallback onEdit,
required final VoidCallback onMove,
required final VoidCallback onStudyOptions,
required final VoidCallback onImport,
required final VoidCallback onArchive,
required final VoidCallback onUnarchive,
required final VoidCallback onDelete,
required final Studyset studyset,
required final ValueChanged<StudysetOption> onOption,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -388,75 +349,15 @@ class const _BottomBar({
: l10n.studysetVisibilityPrivate,
onPressed: onShare,
),
PopupMenuButton<_Option>(
StudysetOptionsButton(
studyset: studyset,
icon: const Icon(LucideIcons.ellipsis),
tooltip: l10n.studysetOptions,
onSelected: (option) => switch (option) {
_Option.edit => onEdit(),
_Option.move => onMove(),
_Option.studyOptions => onStudyOptions(),
_Option.import => onImport(),
_Option.archive => onArchive(),
_Option.unarchive => onUnarchive(),
_Option.delete => onDelete(),
},
itemBuilder: (context) => [
_item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
_item(
_Option.move,
LucideIcons.folderInput,
l10n.studysetMenuMove,
),
_item(
_Option.studyOptions,
LucideIcons.slidersHorizontal,
l10n.studyOptionsTitle,
),
_item(
_Option.import,
LucideIcons.fileInput,
l10n.ankiImportMenu,
),
if (archived)
_item(
_Option.unarchive,
LucideIcons.archiveRestore,
l10n.studysetMenuUnarchive,
)
else
_item(
_Option.archive,
LucideIcons.archive,
l10n.studysetMenuArchive,
),
_item(
_Option.delete,
LucideIcons.trash2,
l10n.studysetMenuDelete,
color: Theme.of(context).colorScheme.error,
),
],
onSelected: onOption,
),
],
),
);
}
/// An option; a [color] marks one that destroys, as deleting.
PopupMenuItem<_Option> _item(
_Option option,
IconData icon,
String label, {
Color? color,
}) => PopupMenuItem(
value: option,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: color),
title: Text(label, style: TextStyle(color: color)),
),
);
}
/// Who made the studyset, where a text button would sit: it only says.

View file

@ -17,16 +17,28 @@ import 'package:material_ui/material_ui.dart';
/// Opens the studyset [id] in the library tab with the screens of the
/// studysets it sits in beneath it, the top level first; [all] are the
/// person's studysets.
void openStudyset(BuildContext context, String id, List<Studyset> all) {
/// person's studysets. With [importContent], its screen asks what to import
/// into it as it opens.
void openStudyset(
BuildContext context,
String id,
List<Studyset> all, {
bool importContent = false,
}) {
final router = GoRouter.of(context);
final path = [for (final studyset in ancestorsOf(id, all)) studyset.id, id];
router.go(RoutePaths.studyset(path.first));
String location(String each) => each == id && importContent
? _importing(each)
: RoutePaths.studyset(each);
router.go(location(path.first));
for (final next in path.skip(1)) {
unawaited(router.push<void>(RoutePaths.studyset(next)));
unawaited(router.push<void>(location(next)));
}
}
/// The studyset [id]'s screen, asking what to import into it as it opens.
String _importing(String id) => '${RoutePaths.studyset(id)}?import=content';
/// Opens the studyset [id] from the screen of the studyset [from] it sits
/// in, at any depth: the screens of the studysets between them come in
/// beneath it. From anywhere else, as [openStudyset] does.
@ -35,16 +47,23 @@ void openStudysetWithin(
required String from,
required String id,
required List<Studyset> all,
bool importContent = false,
}) {
final ancestors = [for (final studyset in ancestorsOf(id, all)) studyset.id];
final start = ancestors.indexOf(from);
if (start < 0) {
openStudyset(context, id, all);
openStudyset(context, id, all, importContent: importContent);
return;
}
final router = GoRouter.of(context);
for (final next in [...ancestors.skip(start + 1), id]) {
unawaited(router.push<void>(RoutePaths.studyset(next)));
unawaited(
router.push<void>(
next == id && importContent
? _importing(next)
: RoutePaths.studyset(next),
),
);
}
}

View file

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
@ -7,7 +9,6 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.da
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
@ -83,20 +84,6 @@ class _StudysetRailState() extends State<StudysetRail> {
}
}
Future<void> _edit(Studyset topic) async {
final bloc = context.read<StudysetListBloc>();
final content = await showStudysetSheet(
context,
initial: topic.content,
insideAnother: true,
inheritedColorName: colorNameOf(widget.parent.id, widget.all),
inheritedLanguages: languagesOf(widget.parent.id, widget.all),
);
if (content != null && content != topic.content) {
bloc.add(StudysetListEventContentChanged(topic.id, content));
}
}
/// Opens [studyset]'s screen, with those of the levels between beneath.
void _open(Studyset studyset) {
if (widget.onOpen case final open?) {
@ -276,12 +263,22 @@ class _StudysetRailState() extends State<StudysetRail> {
: () => _toggle(topic.id),
onOpen: () => _open(topic),
onStudy: () => _study(topic),
onEdit: () => _edit(topic),
onReset: () =>
resetStudysetProgress(this.context, topic),
onDelete: () => deleteStudysetWithUndo(
this.context,
topic,
// The rail's context: it holds the list's bloc and
// outlives the topic's menu.
onOption: (option) => unawaited(
doStudysetOption(
this.context,
option,
topic,
all: widget.all,
onImport: () => openStudysetWithin(
this.context,
from: widget.parent.id,
id: topic.id,
all: widget.all,
importContent: true,
),
),
),
),
if (_unfolded.contains(topic.id))
@ -464,9 +461,7 @@ class const _Topic({
required final VoidCallback? onToggle,
required final VoidCallback onOpen,
required final VoidCallback onStudy,
required final VoidCallback onEdit,
required final VoidCallback onReset,
required final VoidCallback onDelete,
required final ValueChanged<StudysetOption> onOption,
super.key,
}) extends StatefulWidget {
@override
@ -485,22 +480,17 @@ class _TopicState() extends State<_Topic> {
final topic = widget.topic;
return MenuAnchor(
controller: _options,
// Every studyset's options, as its card and its screen have them.
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: widget.onEdit,
child: Text(l10n.studysetMenuEdit),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.rotateCcw),
onPressed: widget.onReset,
child: Text(l10n.studysetMenuReset),
),
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, color: error),
onPressed: widget.onDelete,
child: Text(l10n.studysetMenuDelete, style: TextStyle(color: error)),
),
for (final entry in studysetOptions(l10n, topic))
MenuItemButton(
leadingIcon: Icon(entry.icon, color: entry.destroys ? error : null),
onPressed: () => widget.onOption(entry.option),
child: Text(
entry.label,
style: TextStyle(color: entry.destroys ? error : null),
),
),
],
builder: (context, menu, _) => Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),

View file

@ -6,7 +6,6 @@ import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
@ -21,21 +20,15 @@ import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/widget/copy_update_banner.dart';
import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/model/feedback.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_threads.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
@ -52,15 +45,12 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bl
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -82,6 +72,10 @@ class const StudysetScreen({
/// All the person's studysets, for where it sits and where it can move.
required final List<Studyset> all,
/// Whether it opens asking what to import into it, as Import content
/// from a menu elsewhere asks for: its imports run here.
final bool importOnOpen = false,
super.key,
}) extends StatelessWidget {
@override
@ -212,7 +206,12 @@ class const StudysetScreen({
],
child: CardListScope(
studysetId: studyset.id,
child: _StudysetView(studyset: studyset, all: all),
child: importOnOpen
? _ImportOnOpen(
studysetId: studyset.id,
child: _StudysetView(studyset: studyset, all: all),
)
: _StudysetView(studyset: studyset, all: all),
),
),
);
@ -1353,6 +1352,31 @@ class const _AddMaterial({
}
}
/// [child], asking once, as it first shows, what to import into the
/// studyset [studysetId].
class const _ImportOnOpen({
required final String studysetId,
required final Widget child,
}) extends StatefulWidget {
@override
State<_ImportOnOpen> createState() => _ImportOnOpenState();
}
class _ImportOnOpenState() extends State<_ImportOnOpen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
unawaited(_importContent(context, widget.studysetId));
}
});
}
@override
Widget build(BuildContext context) => widget.child;
}
/// Asks what to import into the studyset [studysetId], and imports it: a
/// learning path from a file in the paths' import format, an Anki deck, or
/// flashcards as text.
@ -1569,187 +1593,25 @@ final class const _StudyTopic(final Studyset studyset) extends _TopicsChoice;
/// Adding material to the studyset.
final class const _AddMaterialChoice() extends _TopicsChoice;
enum _Option() {
edit,
move,
studyOptions,
importContent,
exportContent,
reset,
archive,
unarchive,
delete,
info,
reportToAuthor,
feedback,
}
/// The studyset's options: edit, move, its study options, import and export
/// content, reset its progress, archive or unarchive, delete, and what there is to
/// know about it; for a copy of a published studyset, reporting to its author
/// and its reports and suggestions.
/// The studyset's options, as every menu of a studyset has them
/// (`studysetOptions`); deleting it leaves its screen, up to the studyset
/// it sat in.
class const _Options({
required final Studyset studyset,
required final List<Studyset> all,
}) extends StatelessWidget {
Future<void> _edit(BuildContext context) async {
final bloc = context.read<StudysetListBloc>();
final parentId = studyset.parentId;
final content = await showStudysetSheet(
context,
initial: studyset.content,
insideAnother: parentId != null,
inheritedColorName: parentId == null ? null : colorNameOf(parentId, all),
inheritedLanguages: parentId == null
? (front: null, back: null)
: languagesOf(parentId, all),
);
if (content != null && content != studyset.content) {
bloc.add(StudysetListEventContentChanged(studyset.id, content));
}
}
/// Reports what is wrong with the studyset [origin] names, a copy's, to
/// the author of the published one.
Future<void> _reportToAuthor(
BuildContext context,
PublishedRef origin,
) async {
if (!canWriteToAuthors(context)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).feedbackNeedsIdentity),
),
);
return;
}
if (await showReportSheet(context, authorOnly: true)
case (:final report, toAuthor: _) when context.mounted) {
await sendReportToAuthor(
context,
origin,
FeedbackTargetKind.studyset,
report,
);
}
}
void _archive(BuildContext context, {required bool archive}) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>();
if (archive) {
bloc.add(StudysetListEventArchiveRequested(studyset.id));
} else {
bloc.add(StudysetListEventUnarchiveRequested(studyset.id));
}
showUndoSnackBar(
context,
message: archive
? l10n.libraryStudysetArchived
: l10n.libraryStudysetUnarchived,
onUndo: () => bloc.add(
archive
? StudysetListEventUnarchiveRequested(studyset.id)
: StudysetListEventArchiveRequested(studyset.id),
),
);
}
/// Up to the studyset it sat in, whose screen is beneath, or to the
/// library.
void _leave(BuildContext context) => goUpTo(context, studyset.parentId, all);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// A color marks an option that destroys, as deleting.
PopupMenuItem<_Option> item(
_Option option,
IconData icon,
String text, {
Color? color,
}) => PopupMenuItem(
value: option,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: color),
title: Text(text, style: TextStyle(color: color)),
Widget build(BuildContext context) => StudysetOptionsButton(
studyset: studyset,
onSelected: (option) => unawaited(
doStudysetOption(
context,
option,
studyset,
all: all,
onImport: () => unawaited(_importContent(context, studyset.id)),
onDeleted: () => goUpTo(context, studyset.parentId, all),
),
);
return PopupMenuButton<_Option>(
icon: const Icon(LucideIcons.ellipsisVertical),
tooltip: l10n.studysetOptions,
onSelected: (option) => switch (option) {
_Option.edit => _edit(context),
_Option.move => moveStudyset(context, studyset, all),
_Option.studyOptions => changeStudyOptions(context, studyset),
_Option.importContent => _importContent(context, studyset.id),
_Option.exportContent => exportStudyset(context, studyset),
_Option.reset => resetStudysetProgress(context, studyset),
_Option.archive => _archive(context, archive: true),
_Option.unarchive => _archive(context, archive: false),
_Option.delete => deleteStudysetWithUndo(
context,
studyset,
onDeleted: () => _leave(context),
),
_Option.info => showStudysetInfo(context, studyset),
_Option.reportToAuthor => _reportToAuthor(
context,
studyset.content.published!,
),
_Option.feedback => showStudysetFeedback(
context,
publicId: studyset.content.published!.publication,
title: l10n.studysetFeedback,
),
},
itemBuilder: (context) => [
item(_Option.edit, LucideIcons.pencil, l10n.studysetMenuEdit),
item(_Option.move, LucideIcons.folderInput, l10n.studysetMenuMove),
item(
_Option.studyOptions,
LucideIcons.slidersHorizontal,
l10n.studyOptionsTitle,
),
item(
_Option.importContent,
LucideIcons.fileInput,
l10n.studysetImportContent,
),
item(
_Option.exportContent,
LucideIcons.fileOutput,
l10n.studysetMenuExport,
),
item(_Option.reset, LucideIcons.rotateCcw, l10n.studysetMenuReset),
if (studyset.isArchived)
item(
_Option.unarchive,
LucideIcons.archiveRestore,
l10n.studysetMenuUnarchive,
)
else
item(_Option.archive, LucideIcons.archive, l10n.studysetMenuArchive),
item(
_Option.delete,
LucideIcons.trash2,
l10n.studysetMenuDelete,
color: Theme.of(context).colorScheme.error,
),
item(_Option.info, LucideIcons.info, l10n.moreInfo),
if (fromRelease(studyset.content.published)) ...[
item(_Option.reportToAuthor, LucideIcons.flag, l10n.reportToAuthor),
item(
_Option.feedback,
LucideIcons.messagesSquare,
l10n.studysetFeedback,
),
],
],
);
}
),
);
}

View file

@ -393,8 +393,8 @@ void main() {
expect(find.text('No studysets match your search'), findsOneWidget);
});
testWidgets('the options offer, in this order: Edit, Move, Study options, '
'Import from Anki, Archive and Delete', (tester) async {
testWidgets("the options are every studyset's, as its screen has them, "
'in this order', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
@ -406,9 +406,12 @@ void main() {
'Edit',
'Move to…',
'Study options',
'Import from Anki',
'Import content',
'Export content',
'Reset progress',
'Archive',
'Delete',
'More info',
])
tester.getTopLeft(find.text(label)).dy,
];
@ -437,13 +440,19 @@ void main() {
expect(gateway.moves.single.$2, 'languages');
});
testWidgets('imports from Anki into the studyset from its options', (
tester,
) async {
testWidgets('Import content opens the studyset, asking there what to '
'import: from Anki, say', (tester) async {
stored('japanese', 'Japanese');
await showLibrary(tester);
await choose(tester, 'Japanese', 'Import from Anki');
await choose(tester, 'Japanese', 'Import content');
await tester.pumpAndSettle();
expect(
find.descendant(of: find.byType(AppBar), matching: find.text('Japanese')),
findsOneWidget,
);
await tester.tap(find.text('Anki deck'));
await tester.pump();
await tester.pump();
expect(files.asked, [

View file

@ -50,13 +50,7 @@ void main() {
onOpen: () => calls.add('open'),
onStudy: () => calls.add('study'),
onToggleStar: () => calls.add('star'),
onEdit: () => calls.add('edit'),
onMove: () => calls.add('move'),
onStudyOptions: () => calls.add('study options'),
onImport: () => calls.add('import'),
onArchive: () => calls.add('archive'),
onUnarchive: () => calls.add('unarchive'),
onDelete: () => calls.add('delete'),
onOption: (option) => calls.add(option.name),
),
),
),
@ -237,6 +231,9 @@ void main() {
for (final (option, call) in [
('Edit', 'edit'),
('Import content', 'importContent'),
('Export content', 'exportContent'),
('Reset progress', 'reset'),
('Archive', 'archive'),
('Delete', 'delete'),
]) {

View file

@ -328,8 +328,8 @@ void main() {
expect(find.text('All topics'), findsOneWidget);
});
testWidgets('a long press on a topic opens its options: edit, reset its '
'progress, delete', (tester) async {
testWidgets("a long press on a topic opens its options, every "
"studyset's", (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await openFromLibrary(tester, 'Japanese');
@ -337,9 +337,19 @@ void main() {
await tester.longPress(find.text('Kana'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Reset progress'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
for (final option in [
'Edit',
'Move to…',
'Study options',
'Import content',
'Export content',
'Reset progress',
'Archive',
'Delete',
'More info',
]) {
expect(find.text(option), findsOneWidget, reason: option);
}
});
testWidgets("resetting a topic's progress asks what of it starts afresh, "