feat(studyset): an empty tab says its material is in the topics, and leads there
People testing the app looked for the material of a studyset that groups topics in its own empty tabs, which said "No flashcards yet" and how to add some. A tab with none of its own while the topics hold some now says so: "The flashcards are in its topics", each topic holding some with how much (everything inside it counted), opening it; for flashcards Browse all of them in one list; and, quieter, how to add some here after all. Opened for the first time, such a studyset shows the first tab its topics fill. The tabs' counts stay the studyset's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
8dde0fa42a
commit
31e4d7d64b
8 changed files with 417 additions and 5 deletions
|
|
@ -100,7 +100,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; snack bars floating above the navigation bar as Material 3 places them, in its inverse colours (dark in the light theme), lined up with the floating bar, 560 wide and centred from medium windows on (`FittedSnackBars`, decided 2026-10-12; Flutter's default is Material 2's fixed bar across the foot); `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`, and the library's and Explore's cards each around their studyset; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator` (every spinner is Material 3 Expressive's, in buttons `ButtonLoadingIndicator`, pulling a list down `AppRefreshIndicator`, never Flutter's older circular one), `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `requiredDecoration` (every required field: while empty its outline stronger, in the primary colour, and "Required" below it; never an asterisk) and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `CollapsingRow`, `CountBadge`, `liftWhileDragged`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
||||||
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
|
- **A studyset's options are the same wherever a menu of it opens** (`studysetOptions` and `doStudysetOption` in `studyset_actions.dart`, decided 2026-10-12): its card in the library, its own screen, a topic beside it. Import content runs where the studyset's blocs are, on its screen: from elsewhere it opens the screen, which asks what to import as it opens (`?import=content`).
|
||||||
- **A studyset's content is in three parts** (`feature/studyset/widget/studyset_parts.dart`, decided 2026-10-12, as Vaia has it): Material (its learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first 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 cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; later an exam's date and its schedule, `PathListState.stepsDone`). `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 learning paths, flashcards, notes and files in tabs, the paths first as the way through the rest (decided 2026-10-13: a path is material too, which the plan refers to), each saying how many it holds, an empty one nothing so the four fit a phone more often, each list whole with its tools at the end of the tabs' row, which fold one by one where room runs short rather than cutting the tabs off (`CollapsingRow`: Browse's words first, then the filter into the ⋮ menu, then Browse; as the studyset's own actions fold into icons); decided 2026-10-12 after a trial of sections one below the other, the first three of each behind Show all, as Vaia stacks them: the lists run to hundreds of cards, and tabs are Material 3's way within a part; the tab is kept as the studyset's part, while the material shows, and opened for the first time it opens on the first tab with something in it; a tab with none of its own while the studyset's topics hold some says that it is in them, each topic holding some with how much, everything inside it counted, opening it, Browse all for flashcards, and how to add some here after all, decided 2026-10-14 after people testing the app looked for a grouping studyset's material in its empty tabs; the tabs' counts stay its own), 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 cards due, then the next step of each path under way, or the first path not finished when none is; how far each path has come, its steps done in a bar, each opening the path; managing the paths is the material's; later an exam's date and its schedule, `PathListState.stepsDone`). `StudysetPartSwitch` chooses between them in the header: below expanded windows in a line of its own under the title, from there on in the path's line, which the topics leave free there, centred above the material's column (at the line's end where that column is narrower than the switch; the path folds before it); it ticks as the navigation bar does. **A studyset opens on the part it was left on, on this device** (`StudysetTabs`, `studyset_tabs.json` in the person's folder, read as their data opens, decided 2026-10-11; a tab kept before the parts counts as its part); opened there for the first time, on the study plan while a path is unfinished, else on the material, showing that it loads until it knows rather than one part and then another. Material added or imported shows in its part (`ContentTabCubit`).
|
||||||
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
|
- **Statistics** (`feature/statistics/`, decided 2026-10-12, as Vaia has them): the Profile tab's second tab, for anonymous people too. The time studied in all, per day (every day from the first studied to today counted) and the most in one day, and by days, weeks or months (ten, ten, twelve bars) the study time and the ratings, as bars the app draws itself (`BarChart`, no chart package); a tap on a bar says what it holds. Computed on the device from every review (`studyStatistics`, `StatisticsCubit`), every rating counted, crammed ones too, a fresh start not, and **a review counts as at most a minute of study** (`Review.maxStudyTime`, as Anki does by default: a card left on the screen over lunch isn't an hour of study; stored durations stay as they were), here and in a studyset's Progress. What waits in Recently deleted counts nowhere, here or on Home.
|
||||||
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
- **Lists that can grow long are built as they scroll into view**: a studyset's parts are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside, which a line below the title says; the title has how many cards beside it): grid or detail (one card beside the list, which follows it; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana, accents, tone marks and umlauts left out, so strasse finds Straße and xue xi xué xí; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, by bare letters, 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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,15 @@
|
||||||
"studysetFlashcardsEmpty": "Noch keine Karteikarten",
|
"studysetFlashcardsEmpty": "Noch keine Karteikarten",
|
||||||
"studysetFlashcardsEmptyHint": "Nutze „Material hinzufügen → Karteikarten erstellen“, um welche zu schreiben, oder „⋮ → Inhalt importieren“ für ein Anki-Deck oder eine Liste.",
|
"studysetFlashcardsEmptyHint": "Nutze „Material hinzufügen → Karteikarten erstellen“, um welche zu schreiben, oder „⋮ → Inhalt importieren“ für ein Anki-Deck oder eine Liste.",
|
||||||
"studysetAddMaterial": "Material hinzufügen",
|
"studysetAddMaterial": "Material hinzufügen",
|
||||||
|
"studysetPathsInTopics": "Die Pfade sind in seinen Themen",
|
||||||
|
"studysetPathsInTopicsHint": "Oder schreib hier einen: „Material hinzufügen → Pfad erstellen“.",
|
||||||
|
"studysetFlashcardsInTopics": "Die Karteikarten sind in seinen Themen",
|
||||||
|
"studysetFlashcardsInTopicsHint": "Oder füg hier welche hinzu: „Material hinzufügen → Karteikarten erstellen“.",
|
||||||
|
"studysetNotesInTopics": "Die Notizen sind in seinen Themen",
|
||||||
|
"studysetNotesInTopicsHint": "Oder schreib hier eine: „Material hinzufügen → Notiz erstellen“.",
|
||||||
|
"studysetFilesInTopics": "Die Dateien sind in seinen Themen",
|
||||||
|
"studysetFilesInTopicsHint": "Oder füg hier eine hinzu: „Material hinzufügen → PDF hochladen“.",
|
||||||
|
"studysetBrowseAllFlashcards": "{count, plural, =1{Ansehen} other{Alle {count} ansehen}}",
|
||||||
"studysetCreateFlashcards": "Karteikarten erstellen",
|
"studysetCreateFlashcards": "Karteikarten erstellen",
|
||||||
"studysetStudy": "Lernen",
|
"studysetStudy": "Lernen",
|
||||||
"studysetLearnCount": "{count} lernen",
|
"studysetLearnCount": "{count} lernen",
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,16 @@
|
||||||
"studysetFlashcardsEmpty": "No flashcards yet",
|
"studysetFlashcardsEmpty": "No flashcards yet",
|
||||||
"studysetFlashcardsEmptyHint": "Use \"Add material → Create flashcards\" to write some, or \"⋮ → Import content\" to bring in an Anki deck or a list.",
|
"studysetFlashcardsEmptyHint": "Use \"Add material → Create flashcards\" to write some, or \"⋮ → Import content\" to bring in an Anki deck or a list.",
|
||||||
"studysetAddMaterial": "Add material",
|
"studysetAddMaterial": "Add material",
|
||||||
|
"studysetPathsInTopics": "The paths are in its topics",
|
||||||
|
"studysetPathsInTopicsHint": "Or write one here: \"Add material → Create a path\".",
|
||||||
|
"studysetFlashcardsInTopics": "The flashcards are in its topics",
|
||||||
|
"studysetFlashcardsInTopicsHint": "Or add some here: \"Add material → Create flashcards\".",
|
||||||
|
"studysetNotesInTopics": "The notes are in its topics",
|
||||||
|
"studysetNotesInTopicsHint": "Or write one here: \"Add material → Create a note\".",
|
||||||
|
"studysetFilesInTopics": "The files are in its topics",
|
||||||
|
"studysetFilesInTopicsHint": "Or add one here: \"Add material → Upload a PDF\".",
|
||||||
|
"studysetBrowseAllFlashcards": "{count, plural, =1{Browse it} other{Browse all {count}}}",
|
||||||
|
"@studysetBrowseAllFlashcards": {"description": "Opens the card browser on every flashcard of a studyset's topics.", "placeholders": {"count": {"type": "int"}}},
|
||||||
"studysetCreateFlashcards": "Create flashcards",
|
"studysetCreateFlashcards": "Create flashcards",
|
||||||
"studysetStudy": "Study",
|
"studysetStudy": "Study",
|
||||||
"studysetLearnCount": "Learn {count}",
|
"studysetLearnCount": "Learn {count}",
|
||||||
|
|
|
||||||
|
|
@ -1690,6 +1690,60 @@ abstract class AppLocalizations {
|
||||||
/// **'Add material'**
|
/// **'Add material'**
|
||||||
String get studysetAddMaterial;
|
String get studysetAddMaterial;
|
||||||
|
|
||||||
|
/// No description provided for @studysetPathsInTopics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The paths are in its topics'**
|
||||||
|
String get studysetPathsInTopics;
|
||||||
|
|
||||||
|
/// No description provided for @studysetPathsInTopicsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Or write one here: \"Add material → Create a path\".'**
|
||||||
|
String get studysetPathsInTopicsHint;
|
||||||
|
|
||||||
|
/// No description provided for @studysetFlashcardsInTopics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The flashcards are in its topics'**
|
||||||
|
String get studysetFlashcardsInTopics;
|
||||||
|
|
||||||
|
/// No description provided for @studysetFlashcardsInTopicsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Or add some here: \"Add material → Create flashcards\".'**
|
||||||
|
String get studysetFlashcardsInTopicsHint;
|
||||||
|
|
||||||
|
/// No description provided for @studysetNotesInTopics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The notes are in its topics'**
|
||||||
|
String get studysetNotesInTopics;
|
||||||
|
|
||||||
|
/// No description provided for @studysetNotesInTopicsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Or write one here: \"Add material → Create a note\".'**
|
||||||
|
String get studysetNotesInTopicsHint;
|
||||||
|
|
||||||
|
/// No description provided for @studysetFilesInTopics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The files are in its topics'**
|
||||||
|
String get studysetFilesInTopics;
|
||||||
|
|
||||||
|
/// No description provided for @studysetFilesInTopicsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Or add one here: \"Add material → Upload a PDF\".'**
|
||||||
|
String get studysetFilesInTopicsHint;
|
||||||
|
|
||||||
|
/// Opens the card browser on every flashcard of a studyset's topics.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{Browse it} other{Browse all {count}}}'**
|
||||||
|
String studysetBrowseAllFlashcards(int count);
|
||||||
|
|
||||||
/// No description provided for @studysetCreateFlashcards.
|
/// No description provided for @studysetCreateFlashcards.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1041,6 +1041,46 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetAddMaterial => 'Material hinzufügen';
|
String get studysetAddMaterial => 'Material hinzufügen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPathsInTopics => 'Die Pfade sind in seinen Themen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPathsInTopicsHint =>
|
||||||
|
'Oder schreib hier einen: „Material hinzufügen → Pfad erstellen“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFlashcardsInTopics =>
|
||||||
|
'Die Karteikarten sind in seinen Themen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFlashcardsInTopicsHint =>
|
||||||
|
'Oder füg hier welche hinzu: „Material hinzufügen → Karteikarten erstellen“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetNotesInTopics => 'Die Notizen sind in seinen Themen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetNotesInTopicsHint =>
|
||||||
|
'Oder schreib hier eine: „Material hinzufügen → Notiz erstellen“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFilesInTopics => 'Die Dateien sind in seinen Themen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFilesInTopicsHint =>
|
||||||
|
'Oder füg hier eine hinzu: „Material hinzufügen → PDF hochladen“.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studysetBrowseAllFlashcards(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Alle $count ansehen',
|
||||||
|
one: 'Ansehen',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetCreateFlashcards => 'Karteikarten erstellen';
|
String get studysetCreateFlashcards => 'Karteikarten erstellen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1033,6 +1033,45 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get studysetAddMaterial => 'Add material';
|
String get studysetAddMaterial => 'Add material';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPathsInTopics => 'The paths are in its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetPathsInTopicsHint =>
|
||||||
|
'Or write one here: \"Add material → Create a path\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFlashcardsInTopics => 'The flashcards are in its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFlashcardsInTopicsHint =>
|
||||||
|
'Or add some here: \"Add material → Create flashcards\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetNotesInTopics => 'The notes are in its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetNotesInTopicsHint =>
|
||||||
|
'Or write one here: \"Add material → Create a note\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFilesInTopics => 'The files are in its topics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get studysetFilesInTopicsHint =>
|
||||||
|
'Or add one here: \"Add material → Upload a PDF\".';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String studysetBrowseAllFlashcards(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'Browse all $count',
|
||||||
|
one: 'Browse it',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get studysetCreateFlashcards => 'Create flashcards';
|
String get studysetCreateFlashcards => 'Create flashcards';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubi
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_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/bloc/studyset_list_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
|
@ -292,13 +294,21 @@ class _MaterialPartState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opened here for the first time, the first tab with something in it,
|
/// Opened here for the first time, the first tab with something in it,
|
||||||
/// once the lists are read; with nothing anywhere, the flashcards, which
|
/// once the lists are read; with nothing of its own, the first whose
|
||||||
/// most studysets begin with.
|
/// topics hold something ([inTopics], as far as known), as that tab says
|
||||||
|
/// where it is; with nothing anywhere, the flashcards, which most
|
||||||
|
/// studysets begin with.
|
||||||
void _settleOnContent({
|
void _settleOnContent({
|
||||||
required int? paths,
|
required int? paths,
|
||||||
required int? cards,
|
required int? cards,
|
||||||
required int? notes,
|
required int? notes,
|
||||||
required int? files,
|
required int? files,
|
||||||
|
({int paths, int cards, int notes, int files}) inTopics = (
|
||||||
|
paths: 0,
|
||||||
|
cards: 0,
|
||||||
|
notes: 0,
|
||||||
|
files: 0,
|
||||||
|
),
|
||||||
}) {
|
}) {
|
||||||
if (_settled ||
|
if (_settled ||
|
||||||
paths == null ||
|
paths == null ||
|
||||||
|
|
@ -308,7 +318,10 @@ class _MaterialPartState()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_settled = true;
|
_settled = true;
|
||||||
final tab = switch ((paths, cards, notes, files)) {
|
final own = paths + cards + notes + files > 0;
|
||||||
|
final tab = switch (own
|
||||||
|
? (paths, cards, notes, files)
|
||||||
|
: (inTopics.paths, inTopics.cards, inTopics.notes, inTopics.files)) {
|
||||||
(> 0, _, _, _) => StudysetTab.paths,
|
(> 0, _, _, _) => StudysetTab.paths,
|
||||||
(0, 0, > 0, _) => StudysetTab.notes,
|
(0, 0, > 0, _) => StudysetTab.notes,
|
||||||
(0, 0, 0, > 0) => StudysetTab.files,
|
(0, 0, 0, > 0) => StudysetTab.files,
|
||||||
|
|
@ -342,11 +355,48 @@ class _MaterialPartState()
|
||||||
};
|
};
|
||||||
final noteCount = notes.loaded ? notes.notes.length : null;
|
final noteCount = notes.loaded ? notes.notes.length : null;
|
||||||
final fileCount = files.loaded ? files.rows.length : null;
|
final fileCount = files.loaded ? files.rows.length : null;
|
||||||
|
// The topics holding each kind of material, with how much, everything
|
||||||
|
// inside each counted: where an empty tab's material is instead.
|
||||||
|
final topics = context.watch<StudysetListBloc>().state;
|
||||||
|
List<({Studyset topic, int count})> holding(
|
||||||
|
int Function(StudyProgress) count,
|
||||||
|
) => switch (topics) {
|
||||||
|
StudysetListStateLoaded(:final studysets, :final progress) => [
|
||||||
|
for (final topic in studysets)
|
||||||
|
if (progress[topic.id] case final held? when count(held) > 0)
|
||||||
|
(topic: topic, count: count(held)),
|
||||||
|
],
|
||||||
|
_ => const [],
|
||||||
|
};
|
||||||
|
Widget inTopics(
|
||||||
|
List<({Studyset topic, int count})> held, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String hint,
|
||||||
|
bool browse = false,
|
||||||
|
}) => _SliverInTopics(
|
||||||
|
parentId: widget.studysetId,
|
||||||
|
topics: held,
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
hint: hint,
|
||||||
|
onBrowse: browse ? () => _browse(context) : null,
|
||||||
|
);
|
||||||
|
final pathsInTopics = holding((held) => held.paths);
|
||||||
|
final cardsInTopics = holding((held) => held.cards);
|
||||||
|
final notesInTopics = holding((held) => held.notes);
|
||||||
|
final filesInTopics = holding((held) => held.files);
|
||||||
_settleOnContent(
|
_settleOnContent(
|
||||||
paths: pathCount,
|
paths: pathCount,
|
||||||
cards: cardCount,
|
cards: cardCount,
|
||||||
notes: noteCount,
|
notes: noteCount,
|
||||||
files: fileCount,
|
files: fileCount,
|
||||||
|
inTopics: (
|
||||||
|
paths: pathsInTopics.fold(0, (sum, held) => sum + held.count),
|
||||||
|
cards: cardsInTopics.fold(0, (sum, held) => sum + held.count),
|
||||||
|
notes: notesInTopics.fold(0, (sum, held) => sum + held.count),
|
||||||
|
files: filesInTopics.fold(0, (sum, held) => sum + held.count),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
|
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
|
||||||
void reorderFiles() {
|
void reorderFiles() {
|
||||||
|
|
@ -404,12 +454,30 @@ class _MaterialPartState()
|
||||||
? const _SliverSectionLoading()
|
? const _SliverSectionLoading()
|
||||||
: switch (_shown) {
|
: switch (_shown) {
|
||||||
// The list says itself when it is loading or empty.
|
// The list says itself when it is loading or empty.
|
||||||
|
StudysetTab.paths
|
||||||
|
when paths.loaded &&
|
||||||
|
paths.paths.isEmpty &&
|
||||||
|
pathsInTopics.isNotEmpty =>
|
||||||
|
inTopics(
|
||||||
|
pathsInTopics,
|
||||||
|
icon: LucideIcons.route,
|
||||||
|
title: l10n.studysetPathsInTopics,
|
||||||
|
hint: l10n.studysetPathsInTopicsHint,
|
||||||
|
),
|
||||||
StudysetTab.paths => SliverPathList(
|
StudysetTab.paths => SliverPathList(
|
||||||
studysetId: widget.studysetId,
|
studysetId: widget.studysetId,
|
||||||
reordering: _reorderingPaths,
|
reordering: _reorderingPaths,
|
||||||
),
|
),
|
||||||
StudysetTab.flashcards => switch (cards) {
|
StudysetTab.flashcards => switch (cards) {
|
||||||
CardListStateLoading() => const _SliverSectionLoading(),
|
CardListStateLoading() => const _SliverSectionLoading(),
|
||||||
|
CardListStateLoaded(cards: []) when cardsInTopics.isNotEmpty =>
|
||||||
|
inTopics(
|
||||||
|
cardsInTopics,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
title: l10n.studysetFlashcardsInTopics,
|
||||||
|
hint: l10n.studysetFlashcardsInTopicsHint,
|
||||||
|
browse: true,
|
||||||
|
),
|
||||||
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
||||||
icon: LucideIcons.layers,
|
icon: LucideIcons.layers,
|
||||||
title: l10n.studysetFlashcardsEmpty,
|
title: l10n.studysetFlashcardsEmpty,
|
||||||
|
|
@ -423,6 +491,14 @@ class _MaterialPartState()
|
||||||
},
|
},
|
||||||
StudysetTab.notes => switch (notes) {
|
StudysetTab.notes => switch (notes) {
|
||||||
NoteListState(loaded: false) => const _SliverSectionLoading(),
|
NoteListState(loaded: false) => const _SliverSectionLoading(),
|
||||||
|
NoteListState(notes: [], failed: false)
|
||||||
|
when notesInTopics.isNotEmpty =>
|
||||||
|
inTopics(
|
||||||
|
notesInTopics,
|
||||||
|
icon: LucideIcons.notebookPen,
|
||||||
|
title: l10n.studysetNotesInTopics,
|
||||||
|
hint: l10n.studysetNotesInTopicsHint,
|
||||||
|
),
|
||||||
NoteListState(notes: [], failed: false) => _SliverEmptySection(
|
NoteListState(notes: [], failed: false) => _SliverEmptySection(
|
||||||
icon: LucideIcons.notebookPen,
|
icon: LucideIcons.notebookPen,
|
||||||
title: l10n.notesEmpty,
|
title: l10n.notesEmpty,
|
||||||
|
|
@ -436,6 +512,14 @@ class _MaterialPartState()
|
||||||
},
|
},
|
||||||
_ => switch (files) {
|
_ => switch (files) {
|
||||||
PdfListState(loaded: false) => const _SliverSectionLoading(),
|
PdfListState(loaded: false) => const _SliverSectionLoading(),
|
||||||
|
PdfListState(rows: [], failed: false)
|
||||||
|
when filesInTopics.isNotEmpty =>
|
||||||
|
inTopics(
|
||||||
|
filesInTopics,
|
||||||
|
icon: LucideIcons.folder,
|
||||||
|
title: l10n.studysetFilesInTopics,
|
||||||
|
hint: l10n.studysetFilesInTopicsHint,
|
||||||
|
),
|
||||||
PdfListState(rows: [], failed: false) => _SliverEmptySection(
|
PdfListState(rows: [], failed: false) => _SliverEmptySection(
|
||||||
icon: LucideIcons.folder,
|
icon: LucideIcons.folder,
|
||||||
title: l10n.filesEmpty,
|
title: l10n.filesEmpty,
|
||||||
|
|
@ -556,6 +640,102 @@ class const _SliverSectionLoading() extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A tab of a studyset with none of that material of its own while its
|
||||||
|
/// [topics] hold some (decided 2026-10-14: people testing the app looked
|
||||||
|
/// for the material of a studyset that groups topics in its own empty
|
||||||
|
/// tabs): that it is in them, each topic holding some with how much,
|
||||||
|
/// everything inside it counted, opening it; with [onBrowse] (flashcards)
|
||||||
|
/// all of them in one list; and, quieter, how to add some here after all.
|
||||||
|
class const _SliverInTopics({
|
||||||
|
required final String parentId,
|
||||||
|
required final List<({Studyset topic, int count})> topics,
|
||||||
|
required final IconData icon,
|
||||||
|
required final String title,
|
||||||
|
required final String hint,
|
||||||
|
final VoidCallback? onBrowse,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final muted = theme.colorScheme.onSurfaceVariant;
|
||||||
|
Future<void> open(Studyset topic) async {
|
||||||
|
final all = await PersonScope.of(context).studysets.all();
|
||||||
|
if (context.mounted) {
|
||||||
|
openStudysetWithin(context, from: parentId, id: topic.id, all: all);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 480),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 40, color: muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
for (final (index, (:topic, :count)) in topics.indexed) ...[
|
||||||
|
if (index > 0) const Divider(height: 1),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
topic.content.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$count',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: muted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(LucideIcons.chevronRight, size: 18, color: muted),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => unawaited(open(topic)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (onBrowse case final browse?) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: FilledButton.tonalIcon(
|
||||||
|
onPressed: browse,
|
||||||
|
icon: const Icon(LucideIcons.layoutGrid, size: 18),
|
||||||
|
label: Text(
|
||||||
|
l10n.studysetBrowseAllFlashcards(
|
||||||
|
topics.fold(0, (sum, held) => sum + held.count),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
hint,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A tab with nothing in it yet: what it would hold, and how to add it,
|
/// A tab with nothing in it yet: what it would hold, and how to add it,
|
||||||
/// in the middle of what is left, as an empty list of paths says it.
|
/// in the middle of what is left, as an empty list of paths says it.
|
||||||
class const _SliverEmptySection({
|
class const _SliverEmptySection({
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,80 @@ void main() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group('A studyset whose topics hold its material', () {
|
||||||
|
testWidgets("says where an empty tab's material is, each topic with how "
|
||||||
|
'much it holds, opening it; Browse lists them all', (tester) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
stored('kana', 'Kana', parent: 'japanese');
|
||||||
|
stored('grammar', 'Grammar', parent: 'japanese', position: 'a1');
|
||||||
|
stored('particles', 'Particles', parent: 'grammar');
|
||||||
|
await card('kana', 'あ');
|
||||||
|
await card('kana', 'い');
|
||||||
|
await card('particles', 'は');
|
||||||
|
await showApp(
|
||||||
|
tester,
|
||||||
|
location: RoutePaths.studyset('japanese'),
|
||||||
|
width: 400,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('The flashcards are in its topics'), findsOneWidget);
|
||||||
|
expect(find.text('No flashcards yet'), findsNothing);
|
||||||
|
final kana = find.ancestor(
|
||||||
|
of: find.text('Kana').last,
|
||||||
|
matching: find.byType(ListTile),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: kana, matching: find.text('2')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// Everything inside a topic counts: Grammar holds Particles' card.
|
||||||
|
final grammar = find.ancestor(
|
||||||
|
of: find.text('Grammar').last,
|
||||||
|
matching: find.byType(ListTile),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.descendant(of: grammar, matching: find.text('1')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text('Browse all 3'), findsOneWidget);
|
||||||
|
// The tab counts its own alone.
|
||||||
|
expect(
|
||||||
|
find.descendant(of: find.byType(Tab), matching: find.text('3')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(kana);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(StudysetBreadcrumb),
|
||||||
|
matching: find.text('Kana'),
|
||||||
|
),
|
||||||
|
findsWidgets,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an empty tab whose topics hold none of it says so as ever', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
stored('japanese', 'Japanese');
|
||||||
|
stored('kana', 'Kana', parent: 'japanese');
|
||||||
|
await card('kana', 'あ');
|
||||||
|
await showApp(
|
||||||
|
tester,
|
||||||
|
location: RoutePaths.studyset('japanese'),
|
||||||
|
width: 400,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Notes'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('No notes yet'), findsOneWidget);
|
||||||
|
expect(find.text('The notes are in its topics'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('beside the rail on a narrow desktop window, Add material is '
|
testWidgets('beside the rail on a narrow desktop window, Add material is '
|
||||||
'an icon, so the app bar fits', (tester) async {
|
'an icon, so the app bar fits', (tester) async {
|
||||||
stored('japanese', 'Japanese');
|
stored('japanese', 'Japanese');
|
||||||
|
|
@ -359,7 +433,13 @@ void main() {
|
||||||
await card('kana', 'あ');
|
await card('kana', 'あ');
|
||||||
await openFromLibrary(tester, 'Japanese');
|
await openFromLibrary(tester, 'Japanese');
|
||||||
|
|
||||||
await tester.longPress(find.text('Kana'));
|
// The topic in the rail; the empty tabs list it too.
|
||||||
|
await tester.longPress(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(StudysetRail),
|
||||||
|
matching: find.text('Kana'),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Reset progress'));
|
await tester.tap(find.text('Reset progress'));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue