feat(studyset): the material in tabs, each saying how many it holds
The Material part stacked the flashcards, notes and files as sections, the first three of each behind Show all, as Vaia stacks them. With hundreds of cards that kept the list a tap away, repeated each list's tools and pushed the files far down. They are tabs again, Flashcards, Notes and Files, each with its count, each list whole with its tools at the end of the tabs' row; Progress and the Study plan stay. The tab is kept as the part the studyset was left on, while the material shows, and opened for the first time it opens on a tab with something in it. 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:
parent
ceb7ca1a1e
commit
cf6d58aae7
10 changed files with 336 additions and 219 deletions
|
|
@ -98,7 +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*).
|
||||
- **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`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `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 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, a section each, the first three with Show all), 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; 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.
|
||||
- **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 in the flashcards' section, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): 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).
|
||||
- **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 sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the study plan at once, how far it has come above the paths (`PathImportProgress`). 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. What needs the whole window to be written or picked (a studyset's description, a step's feedback, a map question's region: `editRichText`, `pickRegion`, decided 2026-10-12) is a field in the form (`ChevronField`: the value, a chevron) opening a full-window route with Save in its app bar, where nothing scrolls around a map, so the wheel alone zooms it; a country's own map (Japan's prefectures) is offered there only once the country, or a region within it, is picked.
|
||||
|
|
|
|||
|
|
@ -1691,8 +1691,6 @@
|
|||
"studysetPartMaterial": "Material",
|
||||
"studysetPartProgress": "Fortschritt",
|
||||
"studysetPartPlan": "Lernplan",
|
||||
"studysetShowAll": "Alle zeigen",
|
||||
"studysetShowFewer": "Weniger zeigen",
|
||||
"progressLearned": "Gelernt",
|
||||
"progressLearnedInfo": "Karten, die du drei Wochen oder länger behältst.",
|
||||
"progressOfStudyset": "dieses Lernsets",
|
||||
|
|
|
|||
|
|
@ -1993,8 +1993,6 @@
|
|||
"studysetPartMaterial": "Material",
|
||||
"studysetPartProgress": "Progress",
|
||||
"studysetPartPlan": "Study plan",
|
||||
"studysetShowAll": "Show all",
|
||||
"studysetShowFewer": "Show fewer",
|
||||
"progressLearned": "Learned",
|
||||
"progressLearnedInfo": "Cards you will remember for three weeks or more.",
|
||||
"progressOfStudyset": "of this studyset",
|
||||
|
|
|
|||
|
|
@ -9987,18 +9987,6 @@ abstract class AppLocalizations {
|
|||
/// **'Study plan'**
|
||||
String get studysetPartPlan;
|
||||
|
||||
/// No description provided for @studysetShowAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show all'**
|
||||
String get studysetShowAll;
|
||||
|
||||
/// No description provided for @studysetShowFewer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show fewer'**
|
||||
String get studysetShowFewer;
|
||||
|
||||
/// No description provided for @progressLearned.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -6275,12 +6275,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetPartPlan => 'Lernplan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Alle zeigen';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Weniger zeigen';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Gelernt';
|
||||
|
||||
|
|
|
|||
|
|
@ -6220,12 +6220,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetPartPlan => 'Study plan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Show all';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Show fewer';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Learned';
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -157,26 +158,60 @@ Widget _pullToSync(
|
|||
/// Room below a part for the bar floating over it.
|
||||
const _foot = SliverToBoxAdapter(child: SizedBox(height: 96));
|
||||
|
||||
/// A studyset's material: its flashcards, notes and files, one section
|
||||
/// below the other, each with its first few and Show all for the rest.
|
||||
/// A studyset's material in tabs (decided 2026-10-12, after a trial of
|
||||
/// sections one below the other): Flashcards, Notes and Files, each with
|
||||
/// how many it holds, each list whole in its tab, its tools at the end of
|
||||
/// the tabs' row (Browse and the tag filter for the flashcards, Reorder for
|
||||
/// notes and files). Lists of hundreds of cards show whole at once, rather
|
||||
/// than their first few behind Show all. The tab shown is kept as the
|
||||
/// studyset's, on this device; opened here for the first time, it opens on
|
||||
/// a tab with something in it, the flashcards if they have any, once the
|
||||
/// lists are read. Material added shows in its tab.
|
||||
class const MaterialPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
||||
/// Whether the toolbar's buttons say what they do.
|
||||
required final bool labelled,
|
||||
|
||||
/// Whether it is the part shown: only then is its tab kept, as the part
|
||||
/// the studyset was left on.
|
||||
required final bool shown,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<MaterialPart> createState() => _MaterialPartState();
|
||||
}
|
||||
|
||||
class _MaterialPartState() extends State<MaterialPart> {
|
||||
/// How many of each a section shows until it shows all.
|
||||
static const _preview = 3;
|
||||
class _MaterialPartState()
|
||||
extends State<MaterialPart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// The material's tabs, in order.
|
||||
static const _tabs = [
|
||||
StudysetTab.flashcards,
|
||||
StudysetTab.notes,
|
||||
StudysetTab.files,
|
||||
];
|
||||
|
||||
/// The sections showing all they hold.
|
||||
final _expanded = <StudysetTab>{};
|
||||
/// The tab each studyset was left on, on this device.
|
||||
late final StudysetTabs _kept = PersonScope.of(context).studysetTabs;
|
||||
|
||||
/// The tab it was left on, if any.
|
||||
late final StudysetTab? _keptTab = switch (StudysetTab.values
|
||||
.asNameMap()[_kept.of(widget.studysetId)]) {
|
||||
final tab? when _tabs.contains(tab) => tab,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
late final _controller = TabController(
|
||||
length: _tabs.length,
|
||||
vsync: this,
|
||||
initialIndex: _tabs.indexOf(_keptTab ?? StudysetTab.flashcards),
|
||||
);
|
||||
|
||||
/// Whether the tab to show is settled: the one it was left on, one
|
||||
/// chosen, or the one with content, once the lists are read.
|
||||
late var _settled = _keptTab != null;
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
|
@ -184,11 +219,85 @@ class _MaterialPartState() extends State<MaterialPart> {
|
|||
/// Whether the files show handles to drag them, in their own order.
|
||||
var _reorderingFiles = false;
|
||||
|
||||
int? _limit(StudysetTab tab) => _expanded.contains(tab) ? null : _preview;
|
||||
/// The tab chosen for the content, until the tabs move to it.
|
||||
StudysetTab? _moving;
|
||||
|
||||
void _toggle(StudysetTab tab) => setState(
|
||||
() => _expanded.contains(tab) ? _expanded.remove(tab) : _expanded.add(tab),
|
||||
StudysetTab get _shown => _moving ?? _tabs[_controller.index];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() {
|
||||
if (!_controller.indexIsChanging) {
|
||||
setState(() => _settled = true);
|
||||
_keepTab();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MaterialPart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.shown && !oldWidget.shown) {
|
||||
_keepTab();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the tab shown as the one the studyset was left on, while the
|
||||
/// material is the part shown; it says the part too.
|
||||
void _keepTab() {
|
||||
if (widget.shown && _settled) {
|
||||
unawaited(_kept.keep(widget.studysetId, _shown.name));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The tab shown tapped again goes back to its top.
|
||||
void _tapped(int index) {
|
||||
if (index == _controller.index && widget.scroll.hasClients) {
|
||||
widget.scroll.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opened here for the first time, the tab with something in it, once
|
||||
/// the lists are read: the flashcards if they have any, else the notes,
|
||||
/// else the files, else the flashcards.
|
||||
void _settleOnContent({
|
||||
required int? cards,
|
||||
required int? notes,
|
||||
required int? files,
|
||||
}) {
|
||||
if (_settled || cards == null || notes == null || files == null) {
|
||||
return;
|
||||
}
|
||||
_settled = true;
|
||||
final tab = switch ((cards, notes, files)) {
|
||||
(0, > 0, _) => StudysetTab.notes,
|
||||
(0, 0, > 0) => StudysetTab.files,
|
||||
_ => StudysetTab.flashcards,
|
||||
};
|
||||
if (tab == _shown) {
|
||||
_keepTab();
|
||||
return;
|
||||
}
|
||||
// Shown at once; the tabs follow once this frame is built.
|
||||
_moving = tab;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_moving = null;
|
||||
_controller.index = _tabs.indexOf(tab);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -196,9 +305,105 @@ class _MaterialPartState() extends State<MaterialPart> {
|
|||
final cards = context.watch<CardListBloc>().state;
|
||||
final notes = context.watch<NoteListBloc>().state;
|
||||
final files = context.watch<PdfListBloc>().state;
|
||||
const sidePadding = EdgeInsets.symmetric(horizontal: 12);
|
||||
final cardCount = switch (cards) {
|
||||
CardListStateLoaded(:final cards) => cards.length,
|
||||
_ => null,
|
||||
};
|
||||
final noteCount = notes.loaded ? notes.notes.length : null;
|
||||
final fileCount = files.loaded ? files.rows.length : null;
|
||||
_settleOnContent(cards: cardCount, notes: noteCount, files: fileCount);
|
||||
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
|
||||
final tools = switch (_shown) {
|
||||
StudysetTab.flashcards => [
|
||||
BrowseCardsButton(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: widget.labelled,
|
||||
),
|
||||
if (cards case CardListStateLoaded(cards: [_, ...]))
|
||||
const CardListToolbar(),
|
||||
],
|
||||
StudysetTab.notes => [
|
||||
if (notes.notes.length >= 2 || _reorderingNotes)
|
||||
ReorderToggle(
|
||||
reordering: _reorderingNotes,
|
||||
labelled: widget.labelled,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () =>
|
||||
setState(() => _reorderingNotes = !_reorderingNotes),
|
||||
),
|
||||
],
|
||||
_ => [
|
||||
if (files.rows.length >= 2 || _reorderingFiles)
|
||||
ReorderToggle(
|
||||
reordering: _reorderingFiles,
|
||||
labelled: widget.labelled,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own order, whatever the
|
||||
// files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(() => _reorderingFiles = !_reorderingFiles);
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
final body = !_settled
|
||||
? const _SliverSectionLoading()
|
||||
: switch (_shown) {
|
||||
StudysetTab.flashcards => switch (cards) {
|
||||
CardListStateLoading() => const _SliverSectionLoading(),
|
||||
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
||||
icon: LucideIcons.layers,
|
||||
title: l10n.studysetFlashcardsEmpty,
|
||||
hint: l10n.studysetFlashcardsEmptyHint,
|
||||
),
|
||||
_ => SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
),
|
||||
},
|
||||
StudysetTab.notes => switch (notes) {
|
||||
NoteListState(loaded: false) => const _SliverSectionLoading(),
|
||||
NoteListState(notes: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.notebookPen,
|
||||
title: l10n.notesEmpty,
|
||||
hint: l10n.notesEmptyHint,
|
||||
),
|
||||
_ => SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
_ => switch (files) {
|
||||
PdfListState(loaded: false) => const _SliverSectionLoading(),
|
||||
PdfListState(rows: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.folder,
|
||||
title: l10n.filesEmpty,
|
||||
hint: l10n.filesEmptyHint,
|
||||
),
|
||||
_ => SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
};
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
// Material added or imported shows in its tab.
|
||||
BlocListener<ContentTabCubit, ContentTabState>(
|
||||
listener: (context, request) {
|
||||
if (request case (:final tab, serial: _) when _tabs.contains(tab)) {
|
||||
_controller.animateTo(_tabs.indexOf(tab));
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
|
|
@ -215,182 +420,64 @@ class _MaterialPartState() extends State<MaterialPart> {
|
|||
},
|
||||
),
|
||||
],
|
||||
child: _pullToSync(context, widget.scroll, [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
||||
..._section(
|
||||
tab: StudysetTab.flashcards,
|
||||
title: l10n.studysetTabFlashcards,
|
||||
count: switch (cards) {
|
||||
CardListStateLoaded(:final cards) => cards.length,
|
||||
_ => null,
|
||||
},
|
||||
shown: switch (cards) {
|
||||
CardListStateLoaded(:final shown) => shown.length,
|
||||
_ => 0,
|
||||
},
|
||||
actions: [
|
||||
BrowseCardsButton(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: widget.labelled,
|
||||
),
|
||||
// Narrowing them to a tag where they all show, or while they
|
||||
// are narrowed: otherwise the title keeps its room on a phone.
|
||||
if (_expanded.contains(StudysetTab.flashcards) ||
|
||||
switch (cards) {
|
||||
CardListStateLoaded(:final shown, :final filter) =>
|
||||
shown.length <= _preview || filter != null,
|
||||
_ => false,
|
||||
})
|
||||
const CardListToolbar(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _controller,
|
||||
onTap: _tapped,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// The line below runs the whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
_TabLabel(l10n.studysetTabFlashcards, cardCount),
|
||||
_TabLabel(l10n.studysetTabNotes, noteCount),
|
||||
_TabLabel(l10n.studysetTabFiles, fileCount),
|
||||
],
|
||||
body: switch (cards) {
|
||||
CardListStateLoading() => const _SliverSectionLoading(),
|
||||
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
||||
icon: LucideIcons.layers,
|
||||
title: l10n.studysetFlashcardsEmpty,
|
||||
hint: l10n.studysetFlashcardsEmptyHint,
|
||||
),
|
||||
_ => SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
limit: _limit(StudysetTab.flashcards),
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
),
|
||||
},
|
||||
...tools,
|
||||
],
|
||||
),
|
||||
..._section(
|
||||
tab: StudysetTab.notes,
|
||||
title: l10n.studysetTabNotes,
|
||||
count: notes.loaded ? notes.notes.length : null,
|
||||
shown: notes.notes.length,
|
||||
reorderable: (
|
||||
reordering: _reorderingNotes,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(() {
|
||||
_reorderingNotes = !_reorderingNotes;
|
||||
_expanded.add(StudysetTab.notes);
|
||||
}),
|
||||
),
|
||||
body: switch (notes) {
|
||||
NoteListState(loaded: false) => const _SliverSectionLoading(),
|
||||
NoteListState(notes: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.notebookPen,
|
||||
title: l10n.notesEmpty,
|
||||
hint: l10n.notesEmptyHint,
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
// A list of its own for each tab, from its top.
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(_shown),
|
||||
child: _pullToSync(context, widget.scroll, [body, _foot]),
|
||||
),
|
||||
_ => SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
limit: _reorderingNotes ? null : _limit(StudysetTab.notes),
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
..._section(
|
||||
tab: StudysetTab.files,
|
||||
title: l10n.studysetTabFiles,
|
||||
count: files.loaded ? files.rows.length : null,
|
||||
shown: files.rows.length,
|
||||
reorderable: (
|
||||
reordering: _reorderingFiles,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own order, whatever the
|
||||
// files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_reorderingFiles = !_reorderingFiles;
|
||||
_expanded.add(StudysetTab.files);
|
||||
});
|
||||
},
|
||||
),
|
||||
body: switch (files) {
|
||||
PdfListState(loaded: false) => const _SliverSectionLoading(),
|
||||
PdfListState(rows: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.folder,
|
||||
title: l10n.filesEmpty,
|
||||
hint: l10n.filesEmptyHint,
|
||||
),
|
||||
_ => SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
limit: _reorderingFiles ? null : _limit(StudysetTab.files),
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
),
|
||||
_foot,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// A section: its [title] with how many it holds ([count], once known),
|
||||
/// its [actions], Reorder where [reorderable] and it holds two or more,
|
||||
/// and Show all while it [shown] more than the first few; then [body].
|
||||
List<Widget> _section({
|
||||
required StudysetTab tab,
|
||||
required String title,
|
||||
required int? count,
|
||||
required int shown,
|
||||
required Widget body,
|
||||
List<Widget> actions = const [],
|
||||
({bool reordering, String tooltip, VoidCallback onToggle})? reorderable,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final expanded = _expanded.contains(tab);
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
|
||||
/// A material's tab: its [title] and how many it holds ([count], once
|
||||
/// known).
|
||||
class const _TabLabel(final String title, final int? count)
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// The title takes what the buttons leave, so they end at the
|
||||
// section's edge; a Spacer beside a Flexible title would share
|
||||
// that room with it and leave them halfway.
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(title),
|
||||
if (count case final count?) ...[
|
||||
const SizedBox(width: 6),
|
||||
_CountBadge(count: count),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
...actions,
|
||||
if (reorderable != null &&
|
||||
((count ?? 0) >= 2 || reorderable.reordering))
|
||||
ReorderToggle(
|
||||
reordering: reorderable.reordering,
|
||||
labelled: widget.labelled,
|
||||
tooltip: reorderable.tooltip,
|
||||
onToggle: reorderable.onToggle,
|
||||
),
|
||||
if (shown > _preview && reorderable?.reordering != true)
|
||||
TextButton(
|
||||
onPressed: () => _toggle(tab),
|
||||
child: Text(
|
||||
expanded ? l10n.studysetShowFewer : l10n.studysetShowAll,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body,
|
||||
];
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// How many a section holds, in a small box beside its title.
|
||||
|
|
|
|||
|
|
@ -760,6 +760,18 @@ class _PartsState() extends State<_Parts> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// Keeps [part] as the one the studyset was left on, unless what is kept
|
||||
/// says it already: the material's tab, which the material keeps, is in
|
||||
/// the material.
|
||||
void _keep(StudysetPart part) {
|
||||
final kept = _kept.of(widget.studysetId);
|
||||
if ((StudysetPart.values.asNameMap()[kept] ??
|
||||
StudysetTab.values.asNameMap()[kept]?.part) !=
|
||||
part) {
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows [part], kept for the next time; chosen again, it goes back to
|
||||
/// its top.
|
||||
void _show(StudysetPart part) {
|
||||
|
|
@ -775,7 +787,7 @@ class _PartsState() extends State<_Parts> {
|
|||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
_keep(part);
|
||||
}
|
||||
|
||||
/// Chooses the part to open on, once the paths are known: the plan while
|
||||
|
|
@ -796,7 +808,7 @@ class _PartsState() extends State<_Parts> {
|
|||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
_keep(part);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -863,6 +875,7 @@ class const _Content({
|
|||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.material]!,
|
||||
labelled: labelled,
|
||||
shown: parts.shown == StudysetPart.material,
|
||||
),
|
||||
ProgressPart(
|
||||
studysetId: studysetId,
|
||||
|
|
|
|||
|
|
@ -150,7 +150,13 @@ void main() {
|
|||
expect(tabs.kept, {'japanese': 'plan'});
|
||||
|
||||
await openPart(tester, 'Material');
|
||||
expect(tabs.kept, {'japanese': 'material'});
|
||||
// The material's tab, which says the part too.
|
||||
expect(tabs.kept, {'japanese': 'flashcards'});
|
||||
await tester.tap(find.widgetWithText(Tab, 'Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tabs.kept, {'japanese': 'notes'});
|
||||
await openPart(tester, 'Study plan');
|
||||
expect(tabs.kept, {'japanese': 'plan'});
|
||||
});
|
||||
|
||||
testWidgets('a studyset with only notes opens on them', (tester) async {
|
||||
|
|
|
|||
|
|
@ -613,33 +613,41 @@ void main() {
|
|||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
for (final (part, icon, title, how) in [
|
||||
for (final (part, tab, icon, title, how) in [
|
||||
(
|
||||
'Material',
|
||||
'Flashcards',
|
||||
LucideIcons.layers,
|
||||
'No flashcards yet',
|
||||
'"Add material → Create flashcards"',
|
||||
),
|
||||
(
|
||||
'Material',
|
||||
'Notes',
|
||||
LucideIcons.notebookPen,
|
||||
'No notes yet',
|
||||
'"Add material → Create a note"',
|
||||
),
|
||||
(
|
||||
'Material',
|
||||
'Files',
|
||||
LucideIcons.folder,
|
||||
'No files yet',
|
||||
'"Add material → Upload a PDF"',
|
||||
),
|
||||
(
|
||||
'Study plan',
|
||||
null,
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
]) {
|
||||
await openPart(tester, part);
|
||||
if (tab != null) {
|
||||
await tester.tap(find.widgetWithText(Tab, tab));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
// The box that says so, around its icon, title and hint.
|
||||
final box = find.ancestor(
|
||||
of: find.text(title),
|
||||
|
|
@ -1493,6 +1501,39 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets("the material's tabs say how many each holds, and each "
|
||||
'holds its whole list', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
for (final front in ['猫', '犬', '鳥', '魚', '馬']) {
|
||||
await card('japanese', front);
|
||||
}
|
||||
final notes = NoteRepository(noteGateway);
|
||||
addTearDown(notes.dispose);
|
||||
await notes.create(
|
||||
'japanese',
|
||||
NoteContent(title: 'Particles', body: RichContent.empty()),
|
||||
);
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
String? countOn(String tab) => tester
|
||||
.widgetList<Text>(
|
||||
find.descendant(
|
||||
of: find.widgetWithText(Tab, tab),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
)
|
||||
.map((text) => text.data)
|
||||
.where((data) => data != tab)
|
||||
.singleOrNull;
|
||||
expect(countOn('Flashcards'), '5');
|
||||
expect(countOn('Notes'), '1');
|
||||
expect(countOn('Files'), '0');
|
||||
// All five, no Show all.
|
||||
expect(find.text('馬'), findsOneWidget);
|
||||
expect(find.text('Show all'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping the open part again goes back to its top', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -1506,13 +1547,11 @@ void main() {
|
|||
);
|
||||
}
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
// On screen, not merely built.
|
||||
// On screen, not merely built; on the notes' tab, the only one with
|
||||
// anything in it.
|
||||
final first = find.text('Note 1').hitTestable();
|
||||
expect(first, findsOneWidget);
|
||||
// The first three, until all show.
|
||||
expect(find.text('Note 4'), findsNothing);
|
||||
await tester.tap(find.text('Show all'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Note 4'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.text('Note 3'), const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
|
|||
Loading…
Reference in a new issue