fix(app): studyset tabs built as they scroll, and a Reorder mode for flashcards

A studyset of 1,200 flashcards froze the window for seconds on opening
(7.4 s in a debug test; a release build is faster but still grows with
every card), long enough for the desktop to offer to kill the app: the
tabs held each list as one child of a scrolling view, shrink-wrapped, so
every row was built and laid out at once, and again on every change.

The tabs are now CustomScrollViews holding each list's slivers, built as
they scroll into view (0.07 s for the same 1,200 cards). What a list says
in place of its rows sits in SliverCentered.

On the desktop, Flutter's default drag handle lay over each card's "…",
so a press there began a drag rather than opening the options. Cards now
move in a Reorder mode, as paths do: Reorder beside the tabs (with two
cards or more, all showing), a grip in place of each card's options,
Done to end it. ReorderToggle and liftWhileDragged are shared with the
paths.

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-08 18:22:40 +02:00
commit bb23ca2478
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
16 changed files with 602 additions and 249 deletions

View file

@ -97,7 +97,8 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved. - **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **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, 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 a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. 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 stays empty until something is shared. - **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, 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 a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. 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 stays empty until something is shared.
- **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. `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. 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`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, 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. `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. 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`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `liftWhileDragged` and `SliverCentered`, and the sheets below).
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs 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 grip in place of each row's options), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button.
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.

View file

@ -277,6 +277,7 @@
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}", "cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
"studysetReorder": "Umsortieren", "studysetReorder": "Umsortieren",
"studysetReorderPaths": "Pfade umsortieren", "studysetReorderPaths": "Pfade umsortieren",
"studysetReorderCards": "Karteikarten umsortieren",
"studysetReorderDone": "Fertig", "studysetReorderDone": "Fertig",
"studysetOpenTopics": "Themen anzeigen", "studysetOpenTopics": "Themen anzeigen",
"studysetTopics": "Themen", "studysetTopics": "Themen",
@ -284,8 +285,6 @@
"studysetNewSubStudyset": "Neues Unter-Lernset", "studysetNewSubStudyset": "Neues Unter-Lernset",
"studysetSubStudysetsEmpty": "Noch keine Unter-Lernsets.", "studysetSubStudysetsEmpty": "Noch keine Unter-Lernsets.",
"studysetSubStudysetsEmptyHint": "Teile dieses Lernset in Meilensteine oder Lektionen auf.", "studysetSubStudysetsEmptyHint": "Teile dieses Lernset in Meilensteine oder Lektionen auf.",
"studysetReorder": "Umsortieren",
"studysetReorderDone": "Fertig",
"studysetDragToReorder": "Zum Umsortieren ziehen", "studysetDragToReorder": "Zum Umsortieren ziehen",
"studysetMoveTitle": "Lernset verschieben nach", "studysetMoveTitle": "Lernset verschieben nach",
"studysetMoveTopLevel": "Oberste Ebene", "studysetMoveTopLevel": "Oberste Ebene",

View file

@ -307,6 +307,7 @@
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}}, "@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
"studysetReorder": "Reorder", "studysetReorder": "Reorder",
"studysetReorderPaths": "Reorder paths", "studysetReorderPaths": "Reorder paths",
"studysetReorderCards": "Reorder flashcards",
"studysetReorderDone": "Done", "studysetReorderDone": "Done",
"studysetOpenTopics": "Show topics", "studysetOpenTopics": "Show topics",
"studysetTopics": "Topics", "studysetTopics": "Topics",
@ -314,8 +315,6 @@
"studysetNewSubStudyset": "New sub-studyset", "studysetNewSubStudyset": "New sub-studyset",
"studysetSubStudysetsEmpty": "No sub-studysets yet.", "studysetSubStudysetsEmpty": "No sub-studysets yet.",
"studysetSubStudysetsEmptyHint": "Break this studyset into milestones or lessons.", "studysetSubStudysetsEmptyHint": "Break this studyset into milestones or lessons.",
"studysetReorder": "Reorder",
"studysetReorderDone": "Done",
"studysetDragToReorder": "Drag to reorder", "studysetDragToReorder": "Drag to reorder",
"studysetMoveTitle": "Move studyset to", "studysetMoveTitle": "Move studyset to",
"studysetMoveTopLevel": "Top level", "studysetMoveTopLevel": "Top level",

View file

@ -1720,6 +1720,12 @@ abstract class AppLocalizations {
/// **'Reorder paths'** /// **'Reorder paths'**
String get studysetReorderPaths; String get studysetReorderPaths;
/// No description provided for @studysetReorderCards.
///
/// In en, this message translates to:
/// **'Reorder flashcards'**
String get studysetReorderCards;
/// No description provided for @studysetReorderDone. /// No description provided for @studysetReorderDone.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -1030,6 +1030,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetReorderPaths => 'Pfade umsortieren'; String get studysetReorderPaths => 'Pfade umsortieren';
@override
String get studysetReorderCards => 'Karteikarten umsortieren';
@override @override
String get studysetReorderDone => 'Fertig'; String get studysetReorderDone => 'Fertig';

View file

@ -1023,6 +1023,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get studysetReorderPaths => 'Reorder paths'; String get studysetReorderPaths => 'Reorder paths';
@override
String get studysetReorderCards => 'Reorder flashcards';
@override @override
String get studysetReorderDone => 'Done'; String get studysetReorderDone => 'Done';

View file

@ -0,0 +1,29 @@
import 'package:material_ui/material_ui.dart';
/// How a row of a reorderable list shows while it is dragged elsewhere:
/// rising onto a shadow, as Material's own reorderable lists show it. The
/// shadow takes the row's outline, [borderRadius], inset by [inset] for a
/// row that keeps space around itself; it is also the row's background, for
/// a row without one of its own.
ReorderItemProxyDecorator liftWhileDragged({
EdgeInsets inset = EdgeInsets.zero,
BorderRadius borderRadius = BorderRadius.zero,
}) =>
(child, index, animation) => AnimatedBuilder(
animation: animation,
builder: (context, child) => Stack(
children: [
Positioned.fill(
child: Padding(
padding: inset,
child: Material(
elevation: 6 * Curves.easeInOut.transform(animation.value),
borderRadius: borderRadius,
),
),
),
?child,
],
),
child: child,
);

View file

@ -0,0 +1,40 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Beside a list that can be put in another order: Reorder, and Done while
/// [reordering]; [labelled] with its word, else an icon alone whose
/// [tooltip] says what it reorders.
class const ReorderToggle({
/// Whether the list shows handles to drag its rows by.
required final bool reordering,
/// Whether there is room for the word beside the icon.
required final bool labelled,
/// What the icon alone reorders, e.g. "Reorder paths".
required final String tooltip,
/// Starts reordering, or ends it.
required final VoidCallback onToggle,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final icon = reordering ? LucideIcons.check : LucideIcons.arrowUpDown;
return labelled
? TextButton.icon(
onPressed: onToggle,
icon: Icon(icon, size: 18),
label: Text(
reordering ? l10n.studysetReorderDone : l10n.studysetReorder,
),
)
: IconButton(
tooltip: reordering ? l10n.studysetReorderDone : tooltip,
onPressed: onToggle,
icon: Icon(icon),
);
}
}

View file

@ -0,0 +1,26 @@
import 'package:material_ui/material_ui.dart';
/// [child] in the middle of the room a scrolling list leaves below what
/// comes before it, inside [padding]: what a list shows in place of its rows
/// while they load, or when there are none.
///
/// The padding sits inside the room rather than around it, as a
/// [SliverPadding] would put its bottom below the screen's edge.
class const SliverCentered({
/// What it shows.
required final Widget child,
/// The space kept clear around the room, e.g. for a bar floating over the
/// list's bottom.
final EdgeInsets padding = EdgeInsets.zero,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: padding,
child: Center(child: child),
),
);
}

View file

@ -6,6 +6,8 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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/bloc/card_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -22,7 +24,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart' hide Card; import 'package:material_ui/material_ui.dart' hide Card;
/// Provides [child] with the studyset [studysetId]'s cards ([CardListBloc]), /// Provides [child] with the studyset [studysetId]'s cards ([CardListBloc]),
/// for [CardList] and [CardListToolbar], which can sit apart: the toolbar /// for [SliverCardList] and [CardListToolbar], which can sit apart: the toolbar
/// beside the tabs, the list in one. /// beside the tabs, the list in one.
class const CardListScope({ class const CardListScope({
required final String studysetId, required final String studysetId,
@ -44,45 +46,79 @@ class const CardListScope({
} }
} }
/// A studyset's cards: in order, draggable, each with its tags and opening /// A studyset's cards: in order, each with its tags and opening its editor,
/// its editor, all of them or those with one tag; from the [CardListScope] /// all of them or those with one tag; from the [CardListScope] above. While
/// above. Lives inside a scrolling screen; adding and studying are the /// [reordering] all of them, each has a handle to drag it elsewhere instead
/// screen's. /// of its options. Slivers, for a scrolling view to hold: the cards are
class const CardList({ /// built as they scroll into view, so a studyset of thousands opens as fast
/// as one of ten. Adding and studying are the screen's.
class const SliverCardList({
/// The person's cards. /// The person's cards.
required final CardRepository cards, required final CardRepository cards,
/// The studyset whose cards these are. /// The studyset whose cards these are.
required final String studysetId, required final String studysetId,
/// The space around the cards in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero,
/// Whether the cards show handles to drag them by.
final bool reordering = false,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) =>
BlocBuilder<CardListBloc, CardListState>( BlocBuilder<CardListBloc, CardListState>(
builder: (context, state) => switch (state) { builder: (context, state) => switch (state) {
CardListStateLoading() => const Center(child: AppLoadingIndicator()), CardListStateLoading() => SliverCentered(
CardListStateFailed() => Text( padding: padding,
AppLocalizations.of(context).cardListFailed, child: const AppLoadingIndicator(),
),
CardListStateFailed() => SliverPadding(
padding: padding,
sliver: SliverToBoxAdapter(
child: Text(AppLocalizations.of(context).cardListFailed),
),
),
CardListStateLoaded(cards: []) => SliverCentered(
padding: padding,
child: const _NoCards(),
), ),
CardListStateLoaded(cards: []) => const _NoCards(),
final CardListStateLoaded loaded => final CardListStateLoaded loaded =>
loaded.shown.isEmpty loaded.shown.isEmpty
? const _NothingTagged() ? SliverPadding(
: _Cards( padding: padding,
sliver: const SliverToBoxAdapter(child: _NothingTagged()),
)
: _SliverCards(
cards, cards,
studysetId, studysetId,
loaded.shown, loaded.shown,
loaded.tagging, loaded.tagging,
buried: loaded.buried, buried: loaded.buried,
reorderable: loaded.filter == null, padding: padding,
// Not while only some show, as the places between them
// are unclear.
reordering: reordering && loaded.filter == null,
), ),
}, },
); );
} }
/// Beside the cards: narrowing them to a tag, and managing the tags; from /// Beside the cards: reordering them, narrowing them to a tag, and managing
/// the [CardListScope] above, once there are cards. /// the tags; from the [CardListScope] above, once there are cards. While
class const CardListToolbar({super.key}) extends StatelessWidget { /// [reordering], only Done.
class const CardListToolbar({
/// Whether the cards show handles to drag them by.
final bool reordering = false,
/// Whether there is room for the word Reorder beside its icon.
final bool labelled = true,
/// Starts reordering the cards, or ends it.
final VoidCallback? onReorderToggled,
super.key,
}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<CardListBloc>().state; final state = context.watch<CardListBloc>().state;
@ -91,9 +127,23 @@ class const CardListToolbar({super.key}) extends StatelessWidget {
} }
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final reorder = switch (onReorderToggled) {
final onToggle? => ReorderToggle(
reordering: reordering,
labelled: labelled,
tooltip: l10n.studysetReorderCards,
onToggle: onToggle,
),
null => null,
};
if (reordering) {
return reorder ?? const SizedBox.shrink();
}
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// With two cards or more, all of them showing.
if (state.filter == null && state.cards.length > 1) ?reorder,
IconButton( IconButton(
tooltip: l10n.cardFilterTooltip, tooltip: l10n.cardFilterTooltip,
icon: Icon( icon: Icon(
@ -230,33 +280,30 @@ class const _NoCards() extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
// In the middle of the tab, which the list fills. return Padding(
return Center( padding: const EdgeInsets.all(24),
child: Padding( child: Column(
padding: const EdgeInsets.all(24), mainAxisSize: MainAxisSize.min,
child: Column( children: [
mainAxisSize: MainAxisSize.min, Text(
children: [ l10n.studysetFlashcardsEmpty,
Text( style: theme.textTheme.titleMedium,
l10n.studysetFlashcardsEmpty, ),
style: theme.textTheme.titleMedium, const SizedBox(height: 4),
Text(
l10n.studysetFlashcardsEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 4), ),
Text( ],
l10n.studysetFlashcardsEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
), ),
); );
} }
} }
class const _Cards( class const _SliverCards(
final CardRepository repository, final CardRepository repository,
final String studysetId, final String studysetId,
final List<Card> cards, final List<Card> cards,
@ -265,9 +312,11 @@ class const _Cards(
/// The ids of the cards buried for the day. /// The ids of the cards buried for the day.
required final Set<String> buried, required final Set<String> buried,
/// Whether cards can be dragged to another place: not while only some /// The space around the cards.
/// show, as the places between them are unclear. required final EdgeInsets padding,
required final bool reorderable,
/// Whether the cards show handles to drag them by.
required final bool reordering,
}) extends StatelessWidget { }) extends StatelessWidget {
/// Marks [card] with [report] (null resolving it), saying so with a way /// Marks [card] with [report] (null resolving it), saying so with a way
/// back to how it was. /// back to how it was.
@ -315,52 +364,71 @@ class const _Cards(
} }
@override @override
Widget build(BuildContext context) => ReorderableListView( Widget build(BuildContext context) {
shrinkWrap: true, final indexOf = {for (final (index, card) in cards.indexed) card.id: index};
physics: const NeverScrollableScrollPhysics(), Widget tile(int index) {
buildDefaultDragHandles: reorderable, final card = cards[index];
onReorderItem: (from, to) => context.read<CardListBloc>().add( return CardTile(
CardListEventMoved(cards[from], toIndex: to), key: ValueKey(card.id),
), card: card,
children: [ tags: tagging.of(card.id),
for (final card in cards) buried: buried.contains(card.id),
CardTile( dragIndex: reordering ? index : null,
key: ValueKey(card.id), onReport: () async {
card: card, if (await showReportSheet(context) case final report?
tags: tagging.of(card.id), when context.mounted) {
buried: buried.contains(card.id), _report(context, card, report);
onReport: () async { }
if (await showReportSheet(context) case final report? },
when context.mounted) { onResolve: () => _report(context, card, null),
_report(context, card, report); onInfo: () => showCardInfo(context, card, studysetId: studysetId),
} onBuryToggle: () =>
}, _bury(context, card, buried: buried.contains(card.id)),
onResolve: () => _report(context, card, null), onOpen: () => context.push(RoutePaths.card(studysetId, card.id)),
onInfo: () => showCardInfo(context, card, studysetId: studysetId), onDelete: () async {
onBuryToggle: () => final l10n = AppLocalizations.of(context);
_bury(context, card, buried: buried.contains(card.id)), final deletions = PersonScope.of(context).cardDeletions;
onOpen: () => context.push(RoutePaths.card(studysetId, card.id)), if (!await confirmDestructiveAction(
onDelete: () async { context,
final l10n = AppLocalizations.of(context); title: l10n.cardDeleteTitle,
final deletions = PersonScope.of(context).cardDeletions; message: l10n.cardDeleteMessage,
if (!await confirmDestructiveAction( confirmLabel: l10n.cardTileDelete,
context, ) ||
title: l10n.cardDeleteTitle, !context.mounted) {
message: l10n.cardDeleteMessage, return;
confirmLabel: l10n.cardTileDelete, }
) || // The deletion outlives the tile.
!context.mounted) { deletions.delete(card.id);
return; showUndoSnackBar(
} context,
// The deletion outlives the tile. message: l10n.cardDeleted,
deletions.delete(card.id); onUndo: () => deletions.undo(card.id),
showUndoSnackBar( );
context, },
message: l10n.cardDeleted, );
onUndo: () => deletions.undo(card.id), }
);
}, return SliverPadding(
), padding: padding,
], sliver: reordering
); ? SliverReorderableList(
itemCount: cards.length,
onReorderItem: (from, to) => context.read<CardListBloc>().add(
CardListEventMoved(cards[from], toIndex: to),
),
proxyDecorator: CardTile.lifted,
itemBuilder: (context, index) => tile(index),
)
: SliverList.builder(
itemCount: cards.length,
// A card keeps its place in the tree when others come or go
// before it, an open menu with it.
findChildIndexCallback: (key) => switch (key) {
ValueKey<String>(:final value) => indexOf[value],
_ => null,
},
itemBuilder: (context, index) => tile(index),
),
);
}
} }

View file

@ -1,5 +1,6 @@
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/editor/file_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -11,7 +12,8 @@ import 'package:material_ui/material_ui.dart' hide Card;
/// A card in a studyset's list: a bordered row of one height showing only /// A card in a studyset's list: a bordered row of one height showing only
/// the front, so a long list stays easy to skim, with the card's tags above /// the front, so a long list stays easy to skim, with the card's tags above
/// it and a badge when devices changed it differently. Tapping opens it; /// it and a badge when devices changed it differently. Tapping opens it;
/// "…" offers editing and deleting. /// "…" offers editing and deleting. While its list is reordered, a handle to
/// drag it by replaces "…", and a tap does nothing.
class const CardTile({ class const CardTile({
/// The card. /// The card.
required final Card card, required final Card card,
@ -39,8 +41,20 @@ class const CardTile({
/// Whether it is buried for the day. /// Whether it is buried for the day.
final bool buried = false, final bool buried = false,
/// Its place in a list being reordered, where its handle drags it from;
/// null while the list isn't.
final int? dragIndex,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
/// The space above and below each card, between it and the next.
static const _gap = EdgeInsets.symmetric(vertical: 4);
static const _radius = BorderRadius.all(Radius.circular(12));
/// A card dragged elsewhere, rising onto a shadow of its own outline.
static final lifted = liftWhileDragged(inset: _gap, borderRadius: _radius);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -50,16 +64,16 @@ class const CardTile({
final hasMeta = tags.isNotEmpty; final hasMeta = tags.isNotEmpty;
final hasBadges = report != null || buried; final hasBadges = report != null || buried;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: _gap,
child: Material( child: Material(
color: theme.colorScheme.surface, color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: _radius,
child: InkWell( child: InkWell(
onTap: onOpen, onTap: dragIndex == null ? onOpen : null,
borderRadius: BorderRadius.circular(12), borderRadius: _radius,
child: Ink( child: Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: _radius,
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
// At least this high, so rows are even, yet free to grow with // At least this high, so rows are even, yet free to grow with
@ -129,63 +143,82 @@ class const CardTile({
], ],
), ),
), ),
MenuAnchor( if (dragIndex case final index?)
menuChildren: [ ReorderableDragStartListener(
MenuItemButton( index: index,
leadingIcon: const Icon(LucideIcons.pencil, size: 18), child: IconButton(
onPressed: onOpen, tooltip: l10n.homeDragToReorder,
child: Text(l10n.cardTileEdit), onPressed: () {},
icon: Icon(
LucideIcons.gripHorizontal,
color: theme.colorScheme.onSurfaceVariant,
),
), ),
if (report == null) )
MenuItemButton( else
leadingIcon: const Icon(LucideIcons.flag, size: 18), MenuAnchor(
onPressed: onReport, menuChildren: [
child: Text(l10n.cardReportTitle),
)
else
MenuItemButton( MenuItemButton(
leadingIcon: const Icon( leadingIcon: const Icon(
LucideIcons.flagOff, LucideIcons.pencil,
size: 18, size: 18,
), ),
onPressed: onResolve, onPressed: onOpen,
child: Text(l10n.cardResolveReport), child: Text(l10n.cardTileEdit),
), ),
MenuItemButton( if (report == null)
leadingIcon: Icon( MenuItemButton(
buried ? LucideIcons.sunrise : LucideIcons.moon, leadingIcon: const Icon(
size: 18, LucideIcons.flag,
size: 18,
),
onPressed: onReport,
child: Text(l10n.cardReportTitle),
)
else
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.flagOff,
size: 18,
),
onPressed: onResolve,
child: Text(l10n.cardResolveReport),
),
MenuItemButton(
leadingIcon: Icon(
buried ? LucideIcons.sunrise : LucideIcons.moon,
size: 18,
),
onPressed: onBuryToggle,
child: Text(
buried ? l10n.cardUnbury : l10n.studyBuryCard,
),
), ),
onPressed: onBuryToggle, MenuItemButton(
child: Text( leadingIcon: Icon(
buried ? l10n.cardUnbury : l10n.studyBuryCard, LucideIcons.trash2,
size: 18,
color: theme.colorScheme.error,
),
onPressed: onDelete,
child: Text(
l10n.cardTileDelete,
style: TextStyle(color: theme.colorScheme.error),
),
), ),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info, size: 18),
onPressed: onInfo,
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => IconButton(
tooltip: l10n.cardTileOptions,
icon: const Icon(LucideIcons.ellipsis),
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
), ),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
size: 18,
color: theme.colorScheme.error,
),
onPressed: onDelete,
child: Text(
l10n.cardTileDelete,
style: TextStyle(color: theme.colorScheme.error),
),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info, size: 18),
onPressed: onInfo,
child: Text(l10n.moreInfo),
),
],
builder: (context, menu, _) => IconButton(
tooltip: l10n.cardTileOptions,
icon: const Icon(LucideIcons.ellipsis),
onPressed: () =>
menu.isOpen ? menu.close() : menu.open(),
), ),
),
], ],
), ),
), ),

View file

@ -8,6 +8,8 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.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';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.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';
@ -19,10 +21,14 @@ import 'package:material_ui/material_ui.dart';
/// The studyset [studysetId]'s learning paths, from the [PathListBloc] /// The studyset [studysetId]'s learning paths, from the [PathListBloc]
/// above: each opens to be worked through; completed ones are dimmed; with /// above: each opens to be worked through; completed ones are dimmed; with
/// none, it says so in the middle of the tab. While [reordering], each has /// none, it says so in the middle of the tab. While [reordering], each has
/// a handle to drag it elsewhere instead of its options. /// a handle to drag it elsewhere instead of its options. Slivers, for a
class const PathList({ /// scrolling view to hold: the paths are built as they scroll into view.
class const SliverPathList({
required final String studysetId, required final String studysetId,
final bool reordering = false, final bool reordering = false,
/// The space around the paths in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -32,10 +38,14 @@ class const PathList({
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (!state.loaded) { if (!state.loaded) {
return const Center(child: AppLoadingIndicator()); return SliverCentered(
padding: padding,
child: const AppLoadingIndicator(),
);
} }
if (state.paths.isEmpty) { if (state.paths.isEmpty) {
return Center( return SliverCentered(
padding: padding,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@ -55,17 +65,19 @@ class const PathList({
), ),
); );
} }
final paths = state.paths;
if (reordering) { if (reordering) {
return ReorderableListView( return SliverPadding(
shrinkWrap: true, padding: padding,
physics: const NeverScrollableScrollPhysics(), sliver: SliverReorderableList(
buildDefaultDragHandles: false, itemCount: paths.length,
onReorderItem: (from, to) => context.read<PathListBloc>().add( onReorderItem: (from, to) => context.read<PathListBloc>().add(
PathListEventMoveRequested(state.paths[from].id, to), PathListEventMoveRequested(paths[from].id, to),
), ),
children: [ proxyDecorator: liftWhileDragged(),
for (final (index, path) in state.paths.indexed) itemBuilder: (context, index) {
ListTile( final path = paths[index];
return ListTile(
key: ValueKey(path.id), key: ValueKey(path.id),
leading: const Icon(LucideIcons.route), leading: const Icon(LucideIcons.route),
title: TitleWithKicker( title: TitleWithKicker(
@ -84,21 +96,23 @@ class const PathList({
), ),
), ),
), ),
), );
], },
),
); );
} }
return Column( return SliverPadding(
children: [ padding: padding,
for (final (index, path) in state.paths.indexed) ...[ sliver: SliverList.separated(
if (index > 0) const Divider(height: 1), itemCount: paths.length,
_PathTile( itemBuilder: (context, index) => _PathTile(
studysetId: studysetId, key: ValueKey(paths[index].id),
path: path, studysetId: studysetId,
completed: state.completed.contains(path.id), path: paths[index],
), completed: state.completed.contains(paths[index].id),
], ),
], separatorBuilder: (context, _) => const Divider(height: 1),
),
); );
}, },
); );
@ -111,6 +125,7 @@ class const _PathTile({
required final String studysetId, required final String studysetId,
required final LearningPath path, required final LearningPath path,
required final bool completed, required final bool completed,
super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.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';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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/note/bloc/note_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
@ -18,8 +19,15 @@ import 'package:material_ui/material_ui.dart';
/// The notes of the studyset [studysetId], in order: each with its title and /// The notes of the studyset [studysetId], in order: each with its title and
/// when it was last edited, opening on a tap. With none, it says so. /// when it was last edited, opening on a tap. With none, it says so.
class const NoteList({required final String studysetId, super.key}) /// Slivers, for a scrolling view to hold: the notes are built as they scroll
extends StatelessWidget { /// into view.
class const SliverNoteList({
required final String studysetId,
/// The space around the notes in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero,
super.key,
}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocProvider( Widget build(BuildContext context) => BlocProvider(
create: (context) { create: (context) {
@ -32,11 +40,14 @@ class const NoteList({required final String studysetId, super.key})
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (!state.loaded) { if (!state.loaded) {
return const Center(child: AppLoadingIndicator()); return SliverCentered(
padding: padding,
child: const AppLoadingIndicator(),
);
} }
if (state.notes.isEmpty) { if (state.notes.isEmpty) {
// In the middle of the tab, which the list fills. return SliverCentered(
return Center( padding: padding,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@ -56,13 +67,18 @@ class const NoteList({required final String studysetId, super.key})
), ),
); );
} }
return Column( final notes = state.notes;
children: [ return SliverPadding(
for (final (index, note) in state.notes.indexed) ...[ padding: padding,
if (index > 0) const Divider(height: 1), sliver: SliverList.separated(
_NoteTile(studysetId: studysetId, note: note), itemCount: notes.length,
], itemBuilder: (context, index) => _NoteTile(
], key: ValueKey(notes[index].id),
studysetId: studysetId,
note: notes[index],
),
separatorBuilder: (context, _) => const Divider(height: 1),
),
); );
}, },
), ),
@ -75,6 +91,7 @@ class const NoteList({required final String studysetId, super.key})
class const _NoteTile({ class const _NoteTile({
required final String studysetId, required final String studysetId,
required final Note note, required final Note note,
super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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/pdf_source/bloc/pdf_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_engine.dart';
@ -25,20 +26,29 @@ import 'package:material_ui/material_ui.dart';
/// The studyset's files, the PDFs for now, as [PdfListBloc] above lists /// The studyset's files, the PDFs for now, as [PdfListBloc] above lists
/// them: in the order chosen from the Sort menu, each with what it holds, /// them: in the order chosen from the Sort menu, each with what it holds,
/// how far it is read and its reading deadline, opening on a tap. With none, /// how far it is read and its reading deadline, opening on a tap. With none,
/// it says how to add one. /// it says how to add one. Slivers, for a scrolling view to hold: the files
class const PdfList({required final String studysetId, super.key}) /// are built as they scroll into view.
extends StatelessWidget { class const SliverPdfList({
required final String studysetId,
/// The space around the files in the scrolling view.
final EdgeInsets padding = EdgeInsets.zero,
super.key,
}) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => BlocBuilder<PdfListBloc, PdfListState>( Widget build(BuildContext context) => BlocBuilder<PdfListBloc, PdfListState>(
builder: (context, state) { builder: (context, state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
if (!state.loaded) { if (!state.loaded) {
return const Center(child: AppLoadingIndicator()); return SliverCentered(
padding: padding,
child: const AppLoadingIndicator(),
);
} }
if (state.rows.isEmpty) { if (state.rows.isEmpty) {
// In the middle of the tab, which the list fills. return SliverCentered(
return Center( padding: padding,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@ -64,18 +74,28 @@ class const PdfList({required final String studysetId, super.key})
), ),
); );
} }
return Column( final rows = state.rows;
crossAxisAlignment: CrossAxisAlignment.stretch, return SliverPadding(
children: [ padding: padding,
Align( sliver: SliverMainAxisGroup(
alignment: AlignmentDirectional.centerEnd, slivers: [
child: _SortMenu(sort: state.sort), SliverToBoxAdapter(
), child: Align(
for (final (index, row) in state.rows.indexed) ...[ alignment: AlignmentDirectional.centerEnd,
if (index > 0) const Divider(height: 1), child: _SortMenu(sort: state.sort),
_PdfTile(studysetId: studysetId, row: row), ),
),
SliverList.separated(
itemCount: rows.length,
itemBuilder: (context, index) => _PdfTile(
key: ValueKey(rows[index].source.id),
studysetId: studysetId,
row: rows[index],
),
separatorBuilder: (context, _) => const Divider(height: 1),
),
], ],
], ),
); );
}, },
); );
@ -116,6 +136,7 @@ class const _SortMenu({required final PdfSort sort}) extends StatelessWidget {
class const _PdfTile({ class const _PdfTile({
required final String studysetId, required final String studysetId,
required final PdfRow row, required final PdfRow row,
super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View file

@ -15,12 +15,14 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart'; import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart'; import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart'; 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/undo_snack_bar.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/card/bloc/card_list_bloc.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/export/widget/export_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/export/widget/export_studyset.dart';
@ -465,6 +467,9 @@ class _ContentState()
/// Whether the paths show handles to drag them. /// Whether the paths show handles to drag them.
var _reorderingPaths = false; var _reorderingPaths = false;
/// Whether the flashcards show handles to drag them.
var _reorderingCards = false;
/// Each tab's scrolling, to go back to its top. /// Each tab's scrolling, to go back to its top.
final _scrolls = [ final _scrolls = [
for (final _ in StudysetTab.values) ScrollController(), for (final _ in StudysetTab.values) ScrollController(),
@ -559,33 +564,19 @@ class _ContentState()
} }
} }
/// [child] in the tab [tab], filling it at least, so that what says the /// The tab [tab], scrolling through [list], the slivers of its list: rows
/// tab is empty sits in its middle; room below for the bar floating over /// are built as they scroll into view. Pulled down, it syncs.
/// it. Pulled down, it syncs. Widget _tab(StudysetTab tab, Widget list) => RefreshIndicator(
Widget _tab( onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
StudysetTab tab, child: CustomScrollView(
Widget child, { controller: _scrolls[tab.index],
EdgeInsets padding = _listPadding, // Pullable however little it holds.
}) => LayoutBuilder( physics: const AlwaysScrollableScrollPhysics(),
builder: (context, constraints) => RefreshIndicator( slivers: [list],
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
child: ListView(
controller: _scrolls[tab.index],
// Pullable however little it holds.
physics: const AlwaysScrollableScrollPhysics(),
padding: padding,
children: [
ConstrainedBox(
constraints: BoxConstraints(
minHeight: math.max(0, constraints.maxHeight - padding.vertical),
),
child: child,
),
],
),
), ),
); );
/// Around a tab's list; room below for the bar floating over it.
static const _listPadding = EdgeInsets.fromLTRB(0, 8, 0, 96); static const _listPadding = EdgeInsets.fromLTRB(0, 8, 0, 96);
@override @override
@ -609,6 +600,17 @@ class _ContentState()
} }
}, },
), ),
BlocListener<CardListBloc, CardListState>(
listener: (context, state) {
// Nothing left to reorder, or only some cards showing.
if (_reorderingCards &&
(state is! CardListStateLoaded ||
state.cards.length < 2 ||
state.filter != null)) {
setState(() => _reorderingCards = false);
}
},
),
], ],
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -643,9 +645,16 @@ class _ContentState()
animation: _tabs, animation: _tabs,
builder: (context, _) => builder: (context, _) =>
switch (StudysetTab.values[_tabs.index]) { switch (StudysetTab.values[_tabs.index]) {
StudysetTab.flashcards => const Padding( StudysetTab.flashcards => Padding(
padding: EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: CardListToolbar(), child: CardListToolbar(
reordering: _reorderingCards,
// Below that, an icon alone.
labelled: constraints.maxWidth >= 480,
onReorderToggled: () => setState(
() => _reorderingCards = !_reorderingCards,
),
),
), ),
StudysetTab.paths => _ReorderPaths( StudysetTab.paths => _ReorderPaths(
reordering: _reorderingPaths, reordering: _reorderingPaths,
@ -668,26 +677,34 @@ class _ContentState()
children: [ children: [
_tab( _tab(
StudysetTab.paths, StudysetTab.paths,
PathList( SliverPathList(
studysetId: widget.studysetId, studysetId: widget.studysetId,
reordering: _reorderingPaths, reordering: _reorderingPaths,
padding: _listPadding,
), ),
), ),
_tab( _tab(
StudysetTab.flashcards, StudysetTab.flashcards,
CardList( SliverCardList(
cards: PersonScope.of(context).cards, cards: PersonScope.of(context).cards,
studysetId: widget.studysetId, studysetId: widget.studysetId,
reordering: _reorderingCards,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
), ),
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
), ),
_tab( _tab(
StudysetTab.notes, StudysetTab.notes,
NoteList(studysetId: widget.studysetId), SliverNoteList(
studysetId: widget.studysetId,
padding: _listPadding,
),
), ),
_tab( _tab(
StudysetTab.files, StudysetTab.files,
PdfList(studysetId: widget.studysetId), SliverPdfList(
studysetId: widget.studysetId,
padding: _listPadding,
),
), ),
], ],
), ),
@ -714,24 +731,14 @@ class const _ReorderPaths({
if (count < 2 && !reordering) { if (count < 2 && !reordering) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final icon = reordering ? LucideIcons.check : LucideIcons.arrowUpDown;
return Padding( return Padding(
padding: const EdgeInsets.only(right: 24), padding: const EdgeInsets.only(right: 24),
child: labelled child: ReorderToggle(
? TextButton.icon( reordering: reordering,
onPressed: onToggle, labelled: labelled,
icon: Icon(icon, size: 18), tooltip: l10n.studysetReorderPaths,
label: Text( onToggle: onToggle,
reordering ? l10n.studysetReorderDone : l10n.studysetReorder, ),
),
)
: IconButton(
tooltip: reordering
? l10n.studysetReorderDone
: l10n.studysetReorderPaths,
onPressed: onToggle,
icon: Icon(icon),
),
); );
} }
} }

View file

@ -230,4 +230,90 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('Copied to clipboard'), findsOneWidget); expect(find.text('Copied to clipboard'), findsOneWidget);
}); });
/// The "…" of the card whose front is [front].
Finder optionsOf(String front) => find.descendant(
of: find.widgetWithText(CardTile, front),
matching: find.byTooltip('Options'),
);
testWidgets(
"on desktop, a card's whole … opens its options, with no handle to "
'drag it over it',
(tester) async {
await cards.create('japanese', card('猫', 'cat'));
await cards.create('japanese', card('犬', 'dog'));
await showList(tester);
expect(find.byIcon(Icons.drag_handle), findsNothing);
expect(find.byTooltip('Drag to reorder'), findsNothing);
await tester.tap(optionsOf('猫'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
},
variant: TargetPlatformVariant.only(TargetPlatform.linux),
);
testWidgets('Reorder beside the tabs gives each card a handle to drag it '
'by instead of its options, until Done', (tester) async {
final first = await cards.create('japanese', card('猫', 'cat'));
final second = await cards.create('japanese', card('犬', 'dog'));
await showList(tester);
await tester.tap(find.text('Reorder'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
expect(optionsOf('猫'), findsNothing);
// The first card, dragged below the second.
final rowHeight = tester.getSize(find.widgetWithText(CardTile, '猫')).height;
final drag = await tester.startGesture(
tester.getCenter(find.byTooltip('Drag to reorder').first),
);
for (var step = 0; step < 6; step++) {
await drag.moveBy(Offset(0, rowHeight * 1.5 / 6));
await tester.pump(const Duration(milliseconds: 50));
}
await drag.up();
await tester.pumpAndSettle();
expect(
[
for (final card in await cards.cardsOf('japanese')) card.id,
],
[second, first],
);
expect(
tester.getTopLeft(find.text('犬')).dy,
lessThan(tester.getTopLeft(find.text('猫')).dy),
);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNothing);
expect(optionsOf('猫'), findsOneWidget);
});
testWidgets('Reorder shows with two cards or more, all of them showing', (
tester,
) async {
await cards.create('japanese', card('猫', 'cat'));
await showList(tester);
expect(find.text('Reorder'), findsNothing);
});
testWidgets('builds only the cards in view, so a studyset of a thousand '
'opens as fast as one of ten', (tester) async {
await cards.createAll('japanese', [
for (var i = 0; i < 1000; i++) card('Card $i', 'Back $i'),
]);
await showList(tester);
expect(find.text('Card 0'), findsOneWidget);
expect(find.text('Card 999', skipOffstage: false), findsNothing);
expect(
find.byType(CardTile, skipOffstage: false).evaluate().length,
lessThan(50),
);
});
} }