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
11 changed files with 460 additions and 409 deletions

View file

@ -98,6 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*). - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below). - **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files in tabs, each saying how many it holds, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on a tab with something in it), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`). - **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its flashcards, notes and files in tabs, each saying how many it holds, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on a tab with something in it), Progress (always, nothing yet where nothing was studied, so the switch never changes as reviews are read, decided 2026-10-12: how much is learnt, what is due today, the study time this week from the reviews' durations, each at most `Review.maxStudyTime`, `StudysetActivityCubit`) and Study plan (the paths finished, Next up, the paths). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home. - **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache). - **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).

View file

@ -86,7 +86,12 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
return MaterialPage<void>( return MaterialPage<void>(
key: ValueKey('studyset-$id'), key: ValueKey('studyset-$id'),
name: RoutePaths.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, context,
id, id,
(studyset, all) => StudysetScreen(studyset: studyset, all: all), (studyset, all) =>
StudysetScreen(studyset: studyset, all: all, importOnOpen: importOnOpen),
wayBack: ( wayBack: (
label: AppLocalizations.of(context).studysetBackToLibrary, label: AppLocalizations.of(context).studysetBackToLibrary,
location: RoutePaths.library, 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/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_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.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:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -512,7 +513,6 @@ class _GridState() extends State<_Grid> {
} }
Widget _card(BuildContext context, Studyset studyset) { Widget _card(BuildContext context, Studyset studyset) {
final l10n = AppLocalizations.of(context);
final bloc = context.read<StudysetListBloc>(); final bloc = context.read<StudysetListBloc>();
return StudysetCard( return StudysetCard(
key: ValueKey(studyset.id), key: ValueKey(studyset.id),
@ -533,48 +533,26 @@ class _GridState() extends State<_Grid> {
bloc.add(const StudysetListEventProgressRefreshRequested()); bloc.add(const StudysetListEventProgressRefreshRequested());
}, },
onToggleStar: () => bloc.add(StudysetListEventStarToggled(studyset)), onToggleStar: () => bloc.add(StudysetListEventStarToggled(studyset)),
onEdit: () async { // The grid's context, not the card's: it holds the list's bloc and
final content = await showStudysetSheet( // outlives the card's menu, and the card may be about to go.
context, onOption: (option) => unawaited(
initial: studyset.content, doStudysetOption(
this.context,
option,
studyset,
onImport: () => unawaited(_openToImport(studyset)),
),
),
); );
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 /// Opens [studyset]'s screen, asking what to import into it: its imports
// outlives the card's menu. /// run there.
onMove: () async { Future<void> _openToImport(Studyset studyset) async {
final all = await PersonScope.of(this.context).studysets.all(); final all = await PersonScope.of(context).studysets.all();
if (mounted) { if (mounted) {
await moveStudyset(this.context, studyset, all); openStudyset(context, studyset.id, all, importContent: true);
} }
},
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),
);
} }
Widget _deletedCard(BuildContext context, Studyset studyset) { Widget _deletedCard(BuildContext context, Studyset studyset) {

View file

@ -1,16 +1,290 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/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/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.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/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/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.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_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'; import 'package:material_ui/material_ui.dart';
// What a studyset's options do the same from the library's card and from the // What a studyset's options are and do, the same wherever a menu of it
// studyset's own screen, through the StudysetListBloc above them. // 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 /// Asks where [studyset] goes among [all] the person's studysets, moves it
/// there and says where it went. /// there and says where it went.
@ -117,7 +391,7 @@ Future<void> resetStudysetProgress(
} }
final chosen = await showDialog<({bool cards, bool pdfs, bool paths})>( final chosen = await showDialog<({bool cards, bool pdfs, bool paths})>(
context: context, context: context,
builder: (_) => _ResetDialog(parts: parts), builder: (dialogContext) => _ResetDialog(parts: parts),
); );
if (chosen == null || !context.mounted) { if (chosen == null || !context.mounted) {
return; 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/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/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.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:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -48,26 +49,8 @@ class const StudysetCard({
/// Stars it, or takes its star away. /// Stars it, or takes its star away.
required final VoidCallback onToggleStar, required final VoidCallback onToggleStar,
/// Edits its title, colour and the like. /// Does one of its options, as every menu of a studyset has them.
required final VoidCallback onEdit, required final ValueChanged<StudysetOption> onOption,
/// 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,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -146,15 +129,9 @@ class _StudysetCardState() extends State<StudysetCard> {
onShare: widget.onShare, onShare: widget.onShare,
starred: studyset.isStarred, starred: studyset.isStarred,
starVisible: studyset.isStarred || _hovering, starVisible: studyset.isStarred || _hovering,
archived: archived,
onToggleStar: widget.onToggleStar, onToggleStar: widget.onToggleStar,
onEdit: widget.onEdit, studyset: studyset,
onMove: widget.onMove, onOption: widget.onOption,
onStudyOptions: widget.onStudyOptions,
onImport: widget.onImport,
onArchive: widget.onArchive,
onUnarchive: widget.onUnarchive,
onDelete: widget.onDelete,
), ),
], ],
), ),
@ -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. /// Who made it, the star, whether it is public, and the options.
class const _BottomBar({ class const _BottomBar({
required final String by, required final String by,
@ -339,15 +306,9 @@ class const _BottomBar({
required final VoidCallback onShare, required final VoidCallback onShare,
required final bool starred, required final bool starred,
required final bool starVisible, required final bool starVisible,
required final bool archived,
required final VoidCallback onToggleStar, required final VoidCallback onToggleStar,
required final VoidCallback onEdit, required final Studyset studyset,
required final VoidCallback onMove, required final ValueChanged<StudysetOption> onOption,
required final VoidCallback onStudyOptions,
required final VoidCallback onImport,
required final VoidCallback onArchive,
required final VoidCallback onUnarchive,
required final VoidCallback onDelete,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -388,75 +349,15 @@ class const _BottomBar({
: l10n.studysetVisibilityPrivate, : l10n.studysetVisibilityPrivate,
onPressed: onShare, onPressed: onShare,
), ),
PopupMenuButton<_Option>( StudysetOptionsButton(
studyset: studyset,
icon: const Icon(LucideIcons.ellipsis), icon: const Icon(LucideIcons.ellipsis),
tooltip: l10n.studysetOptions, onSelected: onOption,
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,
),
],
), ),
], ],
), ),
); );
} }
/// 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. /// 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 /// 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 /// studysets it sits in beneath it, the top level first; [all] are the
/// person's studysets. /// person's studysets. With [importContent], its screen asks what to import
void openStudyset(BuildContext context, String id, List<Studyset> all) { /// into it as it opens.
void openStudyset(
BuildContext context,
String id,
List<Studyset> all, {
bool importContent = false,
}) {
final router = GoRouter.of(context); final router = GoRouter.of(context);
final path = [for (final studyset in ancestorsOf(id, all)) studyset.id, id]; 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)) { 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 /// 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 /// in, at any depth: the screens of the studysets between them come in
/// beneath it. From anywhere else, as [openStudyset] does. /// beneath it. From anywhere else, as [openStudyset] does.
@ -35,16 +47,23 @@ void openStudysetWithin(
required String from, required String from,
required String id, required String id,
required List<Studyset> all, required List<Studyset> all,
bool importContent = false,
}) { }) {
final ancestors = [for (final studyset in ancestorsOf(id, all)) studyset.id]; final ancestors = [for (final studyset in ancestorsOf(id, all)) studyset.id];
final start = ancestors.indexOf(from); final start = ancestors.indexOf(from);
if (start < 0) { if (start < 0) {
openStudyset(context, id, all); openStudyset(context, id, all, importContent: importContent);
return; return;
} }
final router = GoRouter.of(context); final router = GoRouter.of(context);
for (final next in [...ancestors.skip(start + 1), id]) { 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:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.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_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.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: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. /// Opens [studyset]'s screen, with those of the levels between beneath.
void _open(Studyset studyset) { void _open(Studyset studyset) {
if (widget.onOpen case final open?) { if (widget.onOpen case final open?) {
@ -276,12 +263,22 @@ class _StudysetRailState() extends State<StudysetRail> {
: () => _toggle(topic.id), : () => _toggle(topic.id),
onOpen: () => _open(topic), onOpen: () => _open(topic),
onStudy: () => _study(topic), onStudy: () => _study(topic),
onEdit: () => _edit(topic), // The rail's context: it holds the list's bloc and
onReset: () => // outlives the topic's menu.
resetStudysetProgress(this.context, topic), onOption: (option) => unawaited(
onDelete: () => deleteStudysetWithUndo( doStudysetOption(
this.context, this.context,
option,
topic, 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)) if (_unfolded.contains(topic.id))
@ -464,9 +461,7 @@ class const _Topic({
required final VoidCallback? onToggle, required final VoidCallback? onToggle,
required final VoidCallback onOpen, required final VoidCallback onOpen,
required final VoidCallback onStudy, required final VoidCallback onStudy,
required final VoidCallback onEdit, required final ValueChanged<StudysetOption> onOption,
required final VoidCallback onReset,
required final VoidCallback onDelete,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -485,21 +480,16 @@ class _TopicState() extends State<_Topic> {
final topic = widget.topic; final topic = widget.topic;
return MenuAnchor( return MenuAnchor(
controller: _options, controller: _options,
// Every studyset's options, as its card and its screen have them.
menuChildren: [ menuChildren: [
for (final entry in studysetOptions(l10n, topic))
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil), leadingIcon: Icon(entry.icon, color: entry.destroys ? error : null),
onPressed: widget.onEdit, onPressed: () => widget.onOption(entry.option),
child: Text(l10n.studysetMenuEdit), child: Text(
entry.label,
style: TextStyle(color: entry.destroys ? error : null),
), ),
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)),
), ),
], ],
builder: (context, menu, _) => Padding( builder: (context, menu, _) => Padding(

View file

@ -6,7 +6,6 @@ import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/dependencies_scope.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_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'; 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/section_card.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.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/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/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.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/model/assistant_topic.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.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_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/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/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_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/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/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/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.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/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_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.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_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.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_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.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. /// All the person's studysets, for where it sits and where it can move.
required final List<Studyset> all, 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, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -211,8 +205,13 @@ class const StudysetScreen({
), ),
], ],
child: CardListScope( child: CardListScope(
studysetId: studyset.id,
child: importOnOpen
? _ImportOnOpen(
studysetId: studyset.id, studysetId: studyset.id,
child: _StudysetView(studyset: studyset, all: all), 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 /// 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 /// learning path from a file in the paths' import format, an Anki deck, or
/// flashcards as text. /// flashcards as text.
@ -1569,187 +1593,25 @@ final class const _StudyTopic(final Studyset studyset) extends _TopicsChoice;
/// Adding material to the studyset. /// Adding material to the studyset.
final class const _AddMaterialChoice() extends _TopicsChoice; final class const _AddMaterialChoice() extends _TopicsChoice;
enum _Option() { /// The studyset's options, as every menu of a studyset has them
edit, /// (`studysetOptions`); deleting it leaves its screen, up to the studyset
move, /// it sat in.
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.
class const _Options({ class const _Options({
required final Studyset studyset, required final Studyset studyset,
required final List<Studyset> all, required final List<Studyset> all,
}) extends StatelessWidget { }) 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) => StudysetOptionsButton(
final l10n = AppLocalizations.of(context); studyset: studyset,
// A color marks an option that destroys, as deleting. onSelected: (option) => unawaited(
PopupMenuItem<_Option> item( doStudysetOption(
_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)),
),
);
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, context,
option,
studyset, studyset,
onDeleted: () => _leave(context), all: all,
onImport: () => unawaited(_importContent(context, studyset.id)),
onDeleted: () => goUpTo(context, studyset.parentId, all),
), ),
_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); expect(find.text('No studysets match your search'), findsOneWidget);
}); });
testWidgets('the options offer, in this order: Edit, Move, Study options, ' testWidgets("the options are every studyset's, as its screen has them, "
'Import from Anki, Archive and Delete', (tester) async { 'in this order', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
await showLibrary(tester); await showLibrary(tester);
@ -406,9 +406,12 @@ void main() {
'Edit', 'Edit',
'Move to…', 'Move to…',
'Study options', 'Study options',
'Import from Anki', 'Import content',
'Export content',
'Reset progress',
'Archive', 'Archive',
'Delete', 'Delete',
'More info',
]) ])
tester.getTopLeft(find.text(label)).dy, tester.getTopLeft(find.text(label)).dy,
]; ];
@ -437,13 +440,19 @@ void main() {
expect(gateway.moves.single.$2, 'languages'); expect(gateway.moves.single.$2, 'languages');
}); });
testWidgets('imports from Anki into the studyset from its options', ( testWidgets('Import content opens the studyset, asking there what to '
tester, 'import: from Anki, say', (tester) async {
) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
await showLibrary(tester); 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(); await tester.pump();
expect(files.asked, [ expect(files.asked, [

View file

@ -50,13 +50,7 @@ void main() {
onOpen: () => calls.add('open'), onOpen: () => calls.add('open'),
onStudy: () => calls.add('study'), onStudy: () => calls.add('study'),
onToggleStar: () => calls.add('star'), onToggleStar: () => calls.add('star'),
onEdit: () => calls.add('edit'), onOption: (option) => calls.add(option.name),
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'),
), ),
), ),
), ),
@ -237,6 +231,9 @@ void main() {
for (final (option, call) in [ for (final (option, call) in [
('Edit', 'edit'), ('Edit', 'edit'),
('Import content', 'importContent'),
('Export content', 'exportContent'),
('Reset progress', 'reset'),
('Archive', 'archive'), ('Archive', 'archive'),
('Delete', 'delete'), ('Delete', 'delete'),
]) { ]) {

View file

@ -328,8 +328,8 @@ void main() {
expect(find.text('All topics'), findsOneWidget); expect(find.text('All topics'), findsOneWidget);
}); });
testWidgets('a long press on a topic opens its options: edit, reset its ' testWidgets("a long press on a topic opens its options, every "
'progress, delete', (tester) async { "studyset's", (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese'); stored('kana', 'Kana', parent: 'japanese');
await openFromLibrary(tester, 'Japanese'); await openFromLibrary(tester, 'Japanese');
@ -337,9 +337,19 @@ void main() {
await tester.longPress(find.text('Kana')); await tester.longPress(find.text('Kana'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget); for (final option in [
expect(find.text('Reset progress'), findsOneWidget); 'Edit',
expect(find.text('Delete'), findsOneWidget); '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, " testWidgets("resetting a topic's progress asks what of it starts afresh, "