feat(studyset): Material, Progress and Study plan in place of the tabs
The studyset's four tabs become three parts, as Vaia has them, chosen with a switch in the header that ticks as the navigation bar does: below expanded windows in a line of its own under the title, above the path and Topics; from there on at the end of the path's line, which the topics leave free there. - Material: the flashcards, notes and files one below the other, the first three of each with Show all; Browse, the tag filter (where all cards show, or while they are narrowed) and Reorder in each section's header; an outlined box where a section is empty. - Progress, once anything in the studyset was studied (a card rated, a path finished): how much is learnt (mature cards), how many studied cards are due today, and how long it was studied this week on this device, with a line of the seven days (StudysetActivityCubit, from the reviews' durations). - Study plan: a ring of the paths finished, Next up (study the cards due, go on with the first path not finished), then the paths. It opens on the part it was left on; a tab kept before counts as its part. Opened for the first time, it opens on the study plan while a path is unfinished, else on the material. Material added lands in its part. 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
1df60c8830
commit
246c9dc143
20 changed files with 1992 additions and 482 deletions
|
|
@ -98,9 +98,9 @@ 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; `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 opens on the tab 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); opened there for the first time, on its paths while one is unfinished, else the first tab with material of its own, showing that it loads until it knows rather than one tab and then another. Material added or imported shows in its own tab (`ContentTabCubit`).
|
||||
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `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): grid or detail (one card beside the list; 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 Paths tab 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.
|
||||
- **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 (only once anything in it was studied: how much is learnt, what is due today, the study time this week from the reviews' durations, `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 at the end of the path's line, which the topics leave free there; 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`).
|
||||
- **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; 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.
|
||||
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
|
||||
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule), `write` (by hand, its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the files' default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later.
|
||||
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
||||
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
|
||||
|
|
|
|||
|
|
@ -1645,5 +1645,30 @@
|
|||
"searchEngineCustom": "Eigene Adresse",
|
||||
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||
"searchEngineCustomInvalid": "Eine http- oder https-Adresse mit %s, wo die Frage hingehört.",
|
||||
"searchEngineCustomSave": "Verwenden"
|
||||
"searchEngineCustomSave": "Verwenden",
|
||||
|
||||
"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",
|
||||
"progressCardsLearned": "{count, plural, =1{{learned} von 1 Karte} other{{learned} von {count} Karten}}",
|
||||
"progressToReview": "Zu wiederholen",
|
||||
"progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.",
|
||||
"progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}",
|
||||
"progressStudyTime": "Lernzeit",
|
||||
"progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.",
|
||||
"progressThisWeek": "diese Woche",
|
||||
"progressMinutes": "{minutes} Min.",
|
||||
"progressHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"planPathsDone": "{count, plural, =1{{done} von 1 Pfad abgeschlossen} other{{done} von {count} Pfaden abgeschlossen}}",
|
||||
"planNoPaths": "Noch keine Lernpfade",
|
||||
"planNextUp": "Als Nächstes",
|
||||
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
||||
"planContinue": "„{title}“ fortsetzen",
|
||||
"planNothing": "Gerade steht nichts an.",
|
||||
"planLearningPaths": "Lernpfade"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1927,5 +1927,37 @@
|
|||
"searchEngineCustom": "Your own address",
|
||||
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||
"searchEngineCustomInvalid": "An http or https address with %s where the question goes.",
|
||||
"searchEngineCustomSave": "Use"
|
||||
"searchEngineCustomSave": "Use",
|
||||
|
||||
"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",
|
||||
"progressCardsLearned": "{count, plural, =1{{learned} of 1 card} other{{learned} of {count} cards}}",
|
||||
"@progressCardsLearned": {"placeholders": {"learned": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"progressToReview": "To review",
|
||||
"progressToReviewInfo": "Cards studied before that are due again today.",
|
||||
"progressDueToday": "{count, plural, =1{card due today} other{cards due today}}",
|
||||
"@progressDueToday": {"placeholders": {"count": {"type": "int"}}},
|
||||
"progressStudyTime": "Study time",
|
||||
"progressStudyTimeInfo": "How long you studied this studyset's flashcards on this device in the last seven days.",
|
||||
"progressThisWeek": "this week",
|
||||
"progressMinutes": "{minutes} min",
|
||||
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
||||
"progressHoursMinutes": "{hours} h {minutes} min",
|
||||
"@progressHoursMinutes": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
|
||||
"planPathsDone": "{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}",
|
||||
"@planPathsDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"planNoPaths": "No learning paths yet",
|
||||
"planNextUp": "Next up",
|
||||
"planReview": "{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}",
|
||||
"@planReview": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planContinue": "Continue “{title}”",
|
||||
"@planContinue": {"placeholders": {"title": {"type": "String"}}},
|
||||
"planNothing": "Nothing due right now.",
|
||||
"planLearningPaths": "Learning paths"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9752,6 +9752,150 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Use'**
|
||||
String get searchEngineCustomSave;
|
||||
|
||||
/// No description provided for @studysetPartMaterial.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Material'**
|
||||
String get studysetPartMaterial;
|
||||
|
||||
/// No description provided for @studysetPartProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress'**
|
||||
String get studysetPartProgress;
|
||||
|
||||
/// No description provided for @studysetPartPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'Learned'**
|
||||
String get progressLearned;
|
||||
|
||||
/// No description provided for @progressLearnedInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards you will remember for three weeks or more.'**
|
||||
String get progressLearnedInfo;
|
||||
|
||||
/// No description provided for @progressOfStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'of this studyset'**
|
||||
String get progressOfStudyset;
|
||||
|
||||
/// No description provided for @progressCardsLearned.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{{learned} of 1 card} other{{learned} of {count} cards}}'**
|
||||
String progressCardsLearned(int learned, int count);
|
||||
|
||||
/// No description provided for @progressToReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To review'**
|
||||
String get progressToReview;
|
||||
|
||||
/// No description provided for @progressToReviewInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards studied before that are due again today.'**
|
||||
String get progressToReviewInfo;
|
||||
|
||||
/// No description provided for @progressDueToday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{card due today} other{cards due today}}'**
|
||||
String progressDueToday(int count);
|
||||
|
||||
/// No description provided for @progressStudyTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study time'**
|
||||
String get progressStudyTime;
|
||||
|
||||
/// No description provided for @progressStudyTimeInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How long you studied this studyset\'s flashcards on this device in the last seven days.'**
|
||||
String get progressStudyTimeInfo;
|
||||
|
||||
/// No description provided for @progressThisWeek.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'this week'**
|
||||
String get progressThisWeek;
|
||||
|
||||
/// No description provided for @progressMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes} min'**
|
||||
String progressMinutes(int minutes);
|
||||
|
||||
/// No description provided for @progressHoursMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{hours} h {minutes} min'**
|
||||
String progressHoursMinutes(int hours, int minutes);
|
||||
|
||||
/// No description provided for @planPathsDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}'**
|
||||
String planPathsDone(int done, int count);
|
||||
|
||||
/// No description provided for @planNoPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No learning paths yet'**
|
||||
String get planNoPaths;
|
||||
|
||||
/// No description provided for @planNextUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next up'**
|
||||
String get planNextUp;
|
||||
|
||||
/// No description provided for @planReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}'**
|
||||
String planReview(int count);
|
||||
|
||||
/// No description provided for @planContinue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue “{title}”'**
|
||||
String planContinue(String title);
|
||||
|
||||
/// No description provided for @planNothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing due right now.'**
|
||||
String get planNothing;
|
||||
|
||||
/// No description provided for @planLearningPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learning paths'**
|
||||
String get planLearningPaths;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -6126,4 +6126,117 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchEngineCustomSave => 'Verwenden';
|
||||
|
||||
@override
|
||||
String get studysetPartMaterial => 'Material';
|
||||
|
||||
@override
|
||||
String get studysetPartProgress => 'Fortschritt';
|
||||
|
||||
@override
|
||||
String get studysetPartPlan => 'Lernplan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Alle zeigen';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Weniger zeigen';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Gelernt';
|
||||
|
||||
@override
|
||||
String get progressLearnedInfo =>
|
||||
'Karten, die du drei Wochen oder länger behältst.';
|
||||
|
||||
@override
|
||||
String get progressOfStudyset => 'dieses Lernsets';
|
||||
|
||||
@override
|
||||
String progressCardsLearned(int learned, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$learned von $count Karten',
|
||||
one: '$learned von 1 Karte',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressToReview => 'Zu wiederholen';
|
||||
|
||||
@override
|
||||
String get progressToReviewInfo =>
|
||||
'Schon gelernte Karten, die heute wieder fällig sind.';
|
||||
|
||||
@override
|
||||
String progressDueToday(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Karten heute fällig',
|
||||
one: 'Karte heute fällig',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressStudyTime => 'Lernzeit';
|
||||
|
||||
@override
|
||||
String get progressStudyTimeInfo =>
|
||||
'Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.';
|
||||
|
||||
@override
|
||||
String get progressThisWeek => 'diese Woche';
|
||||
|
||||
@override
|
||||
String progressMinutes(int minutes) {
|
||||
return '$minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String progressHoursMinutes(int hours, int minutes) {
|
||||
return '$hours Std. $minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$done von $count Pfaden abgeschlossen',
|
||||
one: '$done von 1 Pfad abgeschlossen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNoPaths => 'Noch keine Lernpfade';
|
||||
|
||||
@override
|
||||
String get planNextUp => 'Als Nächstes';
|
||||
|
||||
@override
|
||||
String planReview(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Karteikarten lernen',
|
||||
one: '1 Karteikarte lernen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planContinue(String title) {
|
||||
return '„$title“ fortsetzen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNothing => 'Gerade steht nichts an.';
|
||||
|
||||
@override
|
||||
String get planLearningPaths => 'Lernpfade';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6071,6 +6071,119 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchEngineCustomSave => 'Use';
|
||||
|
||||
@override
|
||||
String get studysetPartMaterial => 'Material';
|
||||
|
||||
@override
|
||||
String get studysetPartProgress => 'Progress';
|
||||
|
||||
@override
|
||||
String get studysetPartPlan => 'Study plan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Show all';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Show fewer';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Learned';
|
||||
|
||||
@override
|
||||
String get progressLearnedInfo =>
|
||||
'Cards you will remember for three weeks or more.';
|
||||
|
||||
@override
|
||||
String get progressOfStudyset => 'of this studyset';
|
||||
|
||||
@override
|
||||
String progressCardsLearned(int learned, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$learned of $count cards',
|
||||
one: '$learned of 1 card',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressToReview => 'To review';
|
||||
|
||||
@override
|
||||
String get progressToReviewInfo =>
|
||||
'Cards studied before that are due again today.';
|
||||
|
||||
@override
|
||||
String progressDueToday(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'cards due today',
|
||||
one: 'card due today',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressStudyTime => 'Study time';
|
||||
|
||||
@override
|
||||
String get progressStudyTimeInfo =>
|
||||
'How long you studied this studyset\'s flashcards on this device in the last seven days.';
|
||||
|
||||
@override
|
||||
String get progressThisWeek => 'this week';
|
||||
|
||||
@override
|
||||
String progressMinutes(int minutes) {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String progressHoursMinutes(int hours, int minutes) {
|
||||
return '$hours h $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$done of $count paths finished',
|
||||
one: '$done of 1 path finished',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNoPaths => 'No learning paths yet';
|
||||
|
||||
@override
|
||||
String get planNextUp => 'Next up';
|
||||
|
||||
@override
|
||||
String planReview(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Study $count flashcards',
|
||||
one: 'Study 1 flashcard',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planContinue(String title) {
|
||||
return 'Continue “$title”';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNothing => 'Nothing due right now.';
|
||||
|
||||
@override
|
||||
String get planLearningPaths => 'Learning paths';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ class const SliverCardList({
|
|||
|
||||
/// The space around the cards in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many cards show at most, the first ones; all for null.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -97,7 +100,11 @@ class const SliverCardList({
|
|||
: _SliverCards(
|
||||
cards,
|
||||
studysetId,
|
||||
loaded.shown,
|
||||
switch (limit) {
|
||||
final limit? when loaded.shown.length > limit =>
|
||||
loaded.shown.sublist(0, limit),
|
||||
_ => loaded.shown,
|
||||
},
|
||||
loaded.tagging,
|
||||
buried: loaded.buried,
|
||||
padding: padding,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -31,6 +32,10 @@ class const SliverNoteList({
|
|||
|
||||
/// The space around the notes in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many notes show at most, the first ones; all for null. Reordering
|
||||
/// shows them all.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -89,7 +94,7 @@ class const SliverNoteList({
|
|||
return SliverPadding(
|
||||
padding: padding,
|
||||
sliver: SliverList.separated(
|
||||
itemCount: notes.length,
|
||||
itemCount: math.min(notes.length, limit ?? notes.length),
|
||||
itemBuilder: (context, index) => _NoteTile(
|
||||
key: ValueKey(notes[index].id),
|
||||
studysetId: studysetId,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -40,6 +41,10 @@ class const SliverPdfList({
|
|||
|
||||
/// The space around the files in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many files show at most, the first ones; all for null. Reordering
|
||||
/// shows them all.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -107,7 +112,7 @@ class const SliverPdfList({
|
|||
),
|
||||
),
|
||||
SliverList.separated(
|
||||
itemCount: rows.length,
|
||||
itemCount: math.min(rows.length, limit ?? rows.length),
|
||||
itemBuilder: (context, index) => _PdfTile(
|
||||
key: ValueKey(rows[index].source.id),
|
||||
studysetId: studysetId,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,35 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// The tabs of a studyset's content, in their order.
|
||||
enum StudysetTab() {
|
||||
/// The kinds of a studyset's content, each in one of its parts.
|
||||
enum StudysetTab(
|
||||
/// The part that shows it.
|
||||
final StudysetPart part,
|
||||
) {
|
||||
/// Its learning paths.
|
||||
paths,
|
||||
paths(StudysetPart.plan),
|
||||
|
||||
/// Its flashcards.
|
||||
flashcards,
|
||||
flashcards(StudysetPart.material),
|
||||
|
||||
/// Its notes.
|
||||
notes,
|
||||
notes(StudysetPart.material),
|
||||
|
||||
/// Its files: its PDFs.
|
||||
files,
|
||||
files(StudysetPart.material),
|
||||
}
|
||||
|
||||
/// The parts of a studyset's screen, in their order: what it holds, how its
|
||||
/// studying goes, and what to study next.
|
||||
enum StudysetPart() {
|
||||
/// Its flashcards, notes and files.
|
||||
material,
|
||||
|
||||
/// How much is learnt, what is due, how long it was studied; once
|
||||
/// anything in it was studied.
|
||||
progress,
|
||||
|
||||
/// Its learning paths, and what to do next.
|
||||
plan,
|
||||
}
|
||||
|
||||
/// The last request to show a content tab, if any; `serial` tells two
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
||||
/// How a studyset was studied: whether anything in it was, and how long it
|
||||
/// was studied on each of the last seven days, today last.
|
||||
typedef StudysetActivityState = ({bool studied, List<Duration> week});
|
||||
|
||||
/// Reads how the studysets [_studysetIds] (a studyset and everything inside
|
||||
/// it) were studied, from the reviews of their cards on this device, and
|
||||
/// again after each review recorded; null until read.
|
||||
class StudysetActivityCubit(
|
||||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final List<String> _studysetIds,
|
||||
) extends Cubit<StudysetActivityState?> {
|
||||
this : super(null) {
|
||||
_changes = _reviews.changes.listen((_) => unawaited(_read()));
|
||||
unawaited(_read());
|
||||
}
|
||||
|
||||
late final StreamSubscription<void> _changes;
|
||||
|
||||
Future<void> _read() async {
|
||||
try {
|
||||
final cardIds = <String>{
|
||||
for (final id in _studysetIds)
|
||||
for (final card in await _cards.cardsOf(id)) card.id,
|
||||
};
|
||||
final reviews = await _reviews.reviewsOf(cardIds);
|
||||
final now = clock.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final week = List.filled(7, Duration.zero);
|
||||
var studied = false;
|
||||
for (final review in reviews) {
|
||||
// A fresh start of a card's progress is no studying.
|
||||
if (!review.mode.rates) {
|
||||
continue;
|
||||
}
|
||||
studied = true;
|
||||
final at = review.reviewedAt.toLocal();
|
||||
// Rounded: a day with a clock change is 23 or 25 hours long.
|
||||
final daysAgo =
|
||||
today.difference(DateTime(at.year, at.month, at.day)).inHours / 24;
|
||||
final index = 6 - daysAgo.round();
|
||||
if (index >= 0 && index < 7) {
|
||||
week[index] += review.duration;
|
||||
}
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit((studied: studied, week: week));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Reading how a studyset was studied failed', error: error);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _changes.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,934 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The name of [part] in the app's language.
|
||||
String studysetPartName(AppLocalizations l10n, StudysetPart part) =>
|
||||
switch (part) {
|
||||
StudysetPart.material => l10n.studysetPartMaterial,
|
||||
StudysetPart.progress => l10n.studysetPartProgress,
|
||||
StudysetPart.plan => l10n.studysetPartPlan,
|
||||
};
|
||||
|
||||
/// The buttons between a studyset's [parts], one pill, the [current] one
|
||||
/// raised in it. Choosing another ticks, as the navigation bar does; the
|
||||
/// current one again is [onChosen] too (going back to its top).
|
||||
class const StudysetPartSwitch({
|
||||
required final List<StudysetPart> parts,
|
||||
required final StudysetPart current,
|
||||
required final ValueChanged<StudysetPart> onChosen,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// How high it is.
|
||||
static const height = 44.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final index = math.max(parts.indexOf(current), 0);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: ShapeDecoration(
|
||||
shape: const StadiumBorder(),
|
||||
color: colors.surfaceContainerHighest,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth / parts.length;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: width * index,
|
||||
width: width,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: const StadiumBorder(),
|
||||
color: colors.surface,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
color: colors.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (final part in parts)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: part == current,
|
||||
inMutuallyExclusiveGroup: true,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: () {
|
||||
if (part != current) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
onChosen(part);
|
||||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
studysetPartName(l10n, part),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: part == current
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: part == current
|
||||
? colors.onSurface
|
||||
: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulled down, a part syncs, however little it holds.
|
||||
Widget _pullToSync(
|
||||
BuildContext context,
|
||||
ScrollController scroll,
|
||||
List<Widget> slivers,
|
||||
) => RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
child: CustomScrollView(
|
||||
controller: scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: slivers,
|
||||
),
|
||||
);
|
||||
|
||||
/// 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.
|
||||
class const MaterialPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
||||
/// Whether the toolbar's buttons say what they do.
|
||||
required final bool labelled,
|
||||
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;
|
||||
|
||||
/// The sections showing all they hold.
|
||||
final _expanded = <StudysetTab>{};
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
||||
/// Whether the files show handles to drag them, in their own order.
|
||||
var _reorderingFiles = false;
|
||||
|
||||
int? _limit(StudysetTab tab) => _expanded.contains(tab) ? null : _preview;
|
||||
|
||||
void _toggle(StudysetTab tab) => setState(
|
||||
() => _expanded.contains(tab) ? _expanded.remove(tab) : _expanded.add(tab),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cards = context.watch<CardListBloc>().state;
|
||||
final notes = context.watch<NoteListBloc>().state;
|
||||
final files = context.watch<PdfListBloc>().state;
|
||||
const sidePadding = EdgeInsets.symmetric(horizontal: 12);
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingNotes && state.notes.length < 2) {
|
||||
setState(() => _reorderingNotes = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PdfListBloc, PdfListState>(
|
||||
listener: (context, state) {
|
||||
if (_reorderingFiles && state.rows.length < 2) {
|
||||
setState(() => _reorderingFiles = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
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(),
|
||||
],
|
||||
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),
|
||||
),
|
||||
},
|
||||
),
|
||||
..._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,
|
||||
),
|
||||
_ => 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),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: count),
|
||||
],
|
||||
const Spacer(),
|
||||
...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.
|
||||
class const _CountBadge({required final int count}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Text('$count', style: theme.textTheme.labelMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section while its list is read.
|
||||
class const _SliverSectionLoading() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A section with nothing in it yet: what it would hold, and how to add
|
||||
/// it, in an outlined box.
|
||||
class const _SliverEmptySection({
|
||||
required final IconData icon,
|
||||
required final String title,
|
||||
required final String hint,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 28, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse beside the flashcards, while there are cards: opens the card
|
||||
/// browser of the studyset [studysetId], over everything inside it too
|
||||
/// when there is something. [labelled] says Browse, else an icon alone.
|
||||
class const BrowseCardsButton({
|
||||
required final String studysetId,
|
||||
required final bool labelled,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCards = context.select<CardListBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
CardListStateLoaded(:final cards) => cards.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
if (!hasCards) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void open() => context.push(
|
||||
RoutePaths.cardBrowser(
|
||||
studysetId,
|
||||
// The rail's list: the studysets inside this one.
|
||||
subtree: switch (context.read<StudysetListBloc>().state) {
|
||||
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
),
|
||||
);
|
||||
return labelled
|
||||
? TextButton.icon(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid, size: 18),
|
||||
label: Text(l10n.cardBrowserBrowse),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid),
|
||||
tooltip: l10n.cardBrowserBrowseTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The studyset [studysetId]'s progress, with everything inside it: how much
|
||||
/// is learnt, what is due today, and how long it was studied this week.
|
||||
/// Shown once anything in it was studied.
|
||||
class const ProgressPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final progress = context.select<StudysetListBloc, StudyProgress?>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) => progress[studysetId],
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
final activity = context.watch<StudysetActivityCubit>().state;
|
||||
if (progress == null || activity == null) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
final learned = progress.mastery ?? 0;
|
||||
final studied = activity.week.fold(Duration.zero, (a, b) => a + b);
|
||||
final big = theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
);
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final toReview = _Tile(
|
||||
title: l10n.progressToReview,
|
||||
info: l10n.progressToReviewInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${progress.reviewsDue}', style: big),
|
||||
Text(l10n.progressDueToday(progress.reviewsDue), style: muted),
|
||||
],
|
||||
),
|
||||
);
|
||||
final studyTime = _Tile(
|
||||
title: l10n.progressStudyTime,
|
||||
info: l10n.progressStudyTimeInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_duration(l10n, studied), style: big, maxLines: 1),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.progressThisWeek, style: muted)),
|
||||
CustomPaint(
|
||||
size: const Size(56, 24),
|
||||
painter: _Sparkline(
|
||||
values: [
|
||||
for (final day in activity.week) day.inSeconds.toDouble(),
|
||||
],
|
||||
color: theme.extras.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return _pullToSync(context, scroll, [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
_Tile(
|
||||
title: l10n.progressLearned,
|
||||
info: l10n.progressLearnedInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text('${(learned * 100).round()} %', style: big),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(l10n.progressOfStudyset, style: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: learned,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
// A plain track: the theme's tinted one reads as full
|
||||
// at 0 % in dark mode.
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
stopIndicatorRadius: 0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.layers,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.progressCardsLearned(
|
||||
progress.mature,
|
||||
progress.cards,
|
||||
),
|
||||
style: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Side by side where both fit, as on most phones.
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth >= 340
|
||||
? IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: toReview),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: studyTime),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
toReview,
|
||||
const SizedBox(height: 12),
|
||||
studyTime,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_foot,
|
||||
]);
|
||||
}
|
||||
|
||||
static String _duration(AppLocalizations l10n, Duration duration) {
|
||||
final minutes = (duration.inSeconds / 60).round();
|
||||
return minutes < 60
|
||||
? l10n.progressMinutes(minutes)
|
||||
: l10n.progressHoursMinutes(minutes ~/ 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
|
||||
/// A box of the progress: its [title] with what it means ([info]) behind
|
||||
/// the ⓘ, then [child].
|
||||
class const _Tile({
|
||||
required final String title,
|
||||
required final String info,
|
||||
required final Widget child,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: info,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [values] as a line, the largest at the top; flat along the foot for
|
||||
/// none.
|
||||
class const _Sparkline({
|
||||
required final List<double> values,
|
||||
required final Color color,
|
||||
}) extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final top = values.fold<double>(0, math.max);
|
||||
final path = Path();
|
||||
for (final (index, value) in values.indexed) {
|
||||
final point = Offset(
|
||||
size.width * index / (values.length - 1),
|
||||
size.height - 2 - (top == 0 ? 0 : (size.height - 4) * value / top),
|
||||
);
|
||||
index == 0
|
||||
? path.moveTo(point.dx, point.dy)
|
||||
: path.lineTo(point.dx, point.dy);
|
||||
}
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Sparkline oldDelegate) =>
|
||||
oldDelegate.color != color || !listEquals(oldDelegate.values, values);
|
||||
}
|
||||
|
||||
/// The studyset [studysetId]'s plan: how many of its learning paths are
|
||||
/// finished, what to do next (study the cards due, go on with the first
|
||||
/// path not finished), then the paths.
|
||||
class const PlanPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
||||
/// Whether Reorder says what it does.
|
||||
required final bool labelled,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<PlanPart> createState() => _PlanPartState();
|
||||
}
|
||||
|
||||
class _PlanPartState() extends State<PlanPart> {
|
||||
/// Whether the paths show handles to drag them.
|
||||
var _reordering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final paths = context.watch<PathListBloc>().state;
|
||||
final due = context.select<StudysetListBloc, int>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) =>
|
||||
progress[widget.studysetId]?.due ?? 0,
|
||||
_ => 0,
|
||||
},
|
||||
);
|
||||
final total = paths.paths.length;
|
||||
final done = paths.paths
|
||||
.where((path) => paths.completed.contains(path.id))
|
||||
.length;
|
||||
final next = paths.completionKnown
|
||||
? paths.paths
|
||||
.where((path) => !paths.completed.contains(path.id))
|
||||
.firstOrNull
|
||||
: null;
|
||||
final tasks = [
|
||||
if (due > 0)
|
||||
_Task(
|
||||
icon: LucideIcons.layers,
|
||||
title: l10n.planReview(due),
|
||||
onTap: () => startStudying(context, widget.studysetId),
|
||||
),
|
||||
if (next != null)
|
||||
_Task(
|
||||
icon: LucideIcons.route,
|
||||
title: l10n.planContinue(next.content.title),
|
||||
onTap: () =>
|
||||
context.push(RoutePaths.path(widget.studysetId, next.id)),
|
||||
),
|
||||
];
|
||||
return BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reordering && state.paths.length < 2) {
|
||||
setState(() => _reordering = false);
|
||||
}
|
||||
},
|
||||
child: _pullToSync(context, widget.scroll, [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 56,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: total == 0 ? 0 : done / total,
|
||||
strokeWidth: 6,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${total == 0 ? 0 : (done * 100 / total).round()}%',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
total == 0
|
||||
? l10n.planNoPaths
|
||||
: l10n.planPathsDone(done, total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(l10n.planNextUp, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
if (tasks.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Text(
|
||||
l10n.planNothing,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverList.list(children: tasks),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.planLearningPaths,
|
||||
style: theme.textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: total),
|
||||
const Spacer(),
|
||||
if (total >= 2 || _reordering)
|
||||
ReorderToggle(
|
||||
reordering: _reordering,
|
||||
labelled: widget.labelled,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(() => _reordering = !_reordering),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reordering,
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
),
|
||||
_foot,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Something to do next: a row that does it on a tap.
|
||||
class const _Task({
|
||||
required final IconData icon,
|
||||
required final String title,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
tileColor: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
leading: Icon(icon, color: colors.primary),
|
||||
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||
|
|
@ -14,13 +13,10 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||
|
|
@ -30,7 +26,6 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
||||
|
|
@ -46,14 +41,13 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_p
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
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:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -64,6 +58,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actio
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -267,8 +262,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget build(BuildContext context) {
|
||||
final studyset = widget.studyset;
|
||||
final all = widget.all;
|
||||
final theme = Theme.of(context);
|
||||
final wide = context.windowSize.isAtLeastExpanded;
|
||||
// The topics, with the bibliography at their foot across from New
|
||||
// sub-studyset: in the side bar from expanded windows on, below that in
|
||||
// a sheet ([_showTopics]).
|
||||
|
|
@ -278,13 +271,39 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
onAddMaterial: _addMaterial.open,
|
||||
footEnd: _BibliographyButton(studyset: studyset),
|
||||
);
|
||||
final content = _Content(studysetId: studyset.id);
|
||||
final description = studyset.content.description.isBlank
|
||||
? null
|
||||
: _Description(studyset: studyset);
|
||||
final color =
|
||||
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
|
||||
Brand.primary;
|
||||
return _Parts(
|
||||
studysetId: studyset.id,
|
||||
all: all,
|
||||
builder: (context, parts) => _withFab(
|
||||
context,
|
||||
rail,
|
||||
_Content(studysetId: studyset.id, parts: parts),
|
||||
parts,
|
||||
description,
|
||||
color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The screen, with what floats over it, around [content] and the
|
||||
/// switch between its [parts].
|
||||
Widget _withFab(
|
||||
BuildContext context,
|
||||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description,
|
||||
Color color,
|
||||
) {
|
||||
final studyset = widget.studyset;
|
||||
final theme = Theme.of(context);
|
||||
final wide = context.windowSize.isAtLeastExpanded;
|
||||
return ScrollHidingFab(
|
||||
// From expanded windows on, Study and Add material are in the app
|
||||
// bar, and asking Claude floats alone.
|
||||
|
|
@ -319,6 +338,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
fab,
|
||||
rail,
|
||||
content,
|
||||
parts,
|
||||
description,
|
||||
roomy: constraints.maxWidth >= _roomyWidth,
|
||||
),
|
||||
|
|
@ -327,6 +347,9 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// How wide each part's button is in the path's line.
|
||||
static const _partWidth = 120.0;
|
||||
|
||||
/// How wide the screen has to be for Add material to show its label in
|
||||
/// the app bar; below, it is an icon, so Study, Share and the options fit.
|
||||
static const _roomyWidth = 960.0;
|
||||
|
|
@ -336,6 +359,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget? fab,
|
||||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description, {
|
||||
required bool roomy,
|
||||
}) {
|
||||
|
|
@ -348,6 +372,16 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
final busy =
|
||||
context.select<PdfListBloc, bool>((bloc) => bloc.state.importing) ||
|
||||
context.select<PathListBloc, bool>((bloc) => bloc.state.importing);
|
||||
// In the header, as Vaia has it (decided 2026-10-12): below expanded
|
||||
// windows in a line of its own under the title, from there on at the end
|
||||
// of the path's line, which the topics leave free there.
|
||||
final partSwitch = parts.settled
|
||||
? StudysetPartSwitch(
|
||||
parts: parts.parts,
|
||||
current: parts.shown,
|
||||
onChosen: parts.show,
|
||||
)
|
||||
: null;
|
||||
return PopScope(
|
||||
canPop: !busy,
|
||||
child: Scaffold(
|
||||
|
|
@ -427,13 +461,27 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!wide)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
// Its room kept while the part to show is settled.
|
||||
child:
|
||||
partSwitch ??
|
||||
const SizedBox(height: StudysetPartSwitch.height),
|
||||
),
|
||||
StudysetBreadcrumb(
|
||||
ancestors: ancestorsOf(studyset.id, all),
|
||||
current: studyset,
|
||||
all: all,
|
||||
enabled: !busy,
|
||||
trailing: wide
|
||||
? null
|
||||
? switch (partSwitch) {
|
||||
final partSwitch? => SizedBox(
|
||||
width: _partWidth * parts.parts.length,
|
||||
child: partSwitch,
|
||||
),
|
||||
null => null,
|
||||
}
|
||||
: _TopicsButton(
|
||||
count: context.select<StudysetListBloc, int>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
|
|
@ -601,438 +649,213 @@ class const _BibliographyButton({required final Studyset studyset})
|
|||
}
|
||||
}
|
||||
|
||||
/// The studyset's content, in tabs that appear as their features land:
|
||||
/// learning paths, flashcards, notes and files, each filling the card below
|
||||
/// the tabs. Beside the tabs, what the tab shown offers: on the flashcards
|
||||
/// narrowing them to a tag and managing tags, on the paths reordering them
|
||||
/// once there are two.
|
||||
/// Which part of the studyset shows, as the switch above it and the content
|
||||
/// share it: whether that is settled yet, the parts there are, the one
|
||||
/// shown, whether anything was studied, each part's scrolling, and how to
|
||||
/// show another.
|
||||
typedef _PartsView = ({
|
||||
bool settled,
|
||||
List<StudysetPart> parts,
|
||||
StudysetPart shown,
|
||||
bool studied,
|
||||
Map<StudysetPart, ScrollController> scrolls,
|
||||
ValueChanged<StudysetPart> show,
|
||||
});
|
||||
|
||||
/// The studyset's content in three parts, chosen with the switch in the
|
||||
/// header: Material (its flashcards, notes and files, a section each),
|
||||
/// Progress (how much is learnt, what is due, the study time this week;
|
||||
/// only once anything in it was studied) and Study plan (its learning
|
||||
/// paths, and what to do next). [builder] gets which shows.
|
||||
///
|
||||
/// It opens on the tab it was left on, on this device ([StudysetTabs],
|
||||
/// It opens on the part it was left on, on this device ([StudysetTabs],
|
||||
/// decided 2026-10-11). Opened here for the first time, it opens on the
|
||||
/// first tab with something of the studyset's own in it (its topics'
|
||||
/// content is in theirs), in their order: paths not completed yet,
|
||||
/// flashcards, notes, files; on the flashcards when there is nothing
|
||||
/// (decided 2026-10-03); until it knows which, it shows that it is loading
|
||||
/// rather than one tab and then another. Material added or imported shows in
|
||||
/// its own tab ([ContentTabCubit]). Once the person chose a tab, or material
|
||||
/// was added, opening no longer moves it.
|
||||
class const _Content({required final String studysetId})
|
||||
extends StatefulWidget {
|
||||
/// study plan while a path is not completed yet, else on the material;
|
||||
/// until it knows which, it shows that it is loading rather than one part
|
||||
/// and then another. Material added or imported shows in its part
|
||||
/// ([ContentTabCubit]). Once the person chose a part, or material was
|
||||
/// added, opening no longer moves it.
|
||||
class const _Parts({
|
||||
required final String studysetId,
|
||||
|
||||
/// All the person's studysets, for what is inside this one.
|
||||
required final List<Studyset> all,
|
||||
required final Widget Function(BuildContext context, _PartsView parts)
|
||||
builder,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_Content> createState() => _ContentState();
|
||||
State<_Parts> createState() => _PartsState();
|
||||
}
|
||||
|
||||
class _ContentState()
|
||||
extends State<_Content>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _tabs = TabController(
|
||||
length: StudysetTab.values.length,
|
||||
vsync: this,
|
||||
initialIndex: _active,
|
||||
);
|
||||
|
||||
/// The tab each studyset was left on, on this device.
|
||||
class _PartsState() extends State<_Parts> {
|
||||
/// The part each studyset was left on, on this device.
|
||||
late final StudysetTabs _kept = PersonScope.of(context).studysetTabs;
|
||||
|
||||
/// Whether the tab shown is settled: the one it was left on, chosen on
|
||||
/// opening, by the person, or for material added. Until then, no tab
|
||||
/// Whether the part shown is settled: the one it was left on, chosen on
|
||||
/// opening, by the person, or for material added. Until then, no part
|
||||
/// shows.
|
||||
var _settled = false;
|
||||
|
||||
/// Whether the paths show handles to drag them.
|
||||
var _reorderingPaths = false;
|
||||
/// The part shown, once settled.
|
||||
var _part = StudysetPart.material;
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
||||
/// Whether the files show handles to drag them, in their own order.
|
||||
var _reorderingFiles = false;
|
||||
|
||||
/// Each tab's scrolling, to go back to its top.
|
||||
final _scrolls = [for (final _ in StudysetTab.values) ScrollController()];
|
||||
|
||||
/// The tab shown, once the tabs have settled.
|
||||
var _active = StudysetTab.flashcards.index;
|
||||
|
||||
/// Whether the studyset itself holds cards, notes and PDFs, read once on
|
||||
/// opening; null until then.
|
||||
({bool cards, bool notes, bool pdfs})? _holds;
|
||||
/// Each part's scrolling, to go back to its top.
|
||||
final _scrolls = {
|
||||
for (final part in StudysetPart.values) part: ScrollController(),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (StudysetTab.values.asNameMap()[_kept.of(widget.studysetId)]
|
||||
case final tab?) {
|
||||
_active = tab.index;
|
||||
final kept = _kept.of(widget.studysetId);
|
||||
// A part, or a tab kept before there were parts.
|
||||
if (StudysetPart.values.asNameMap()[kept] ??
|
||||
StudysetTab.values.asNameMap()[kept]?.part
|
||||
case final part?) {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
}
|
||||
_tabs.addListener(() {
|
||||
// Swiping to a tab is choosing it as much as tapping it is.
|
||||
_settle();
|
||||
if (!_tabs.indexIsChanging) {
|
||||
_active = _tabs.index;
|
||||
unawaited(
|
||||
_kept.keep(widget.studysetId, StudysetTab.values[_active].name),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!_settled) {
|
||||
unawaited(_readWhatItHolds());
|
||||
}
|
||||
}
|
||||
|
||||
void _settle() {
|
||||
if (!_settled && mounted) {
|
||||
setState(() => _settled = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
for (final scroll in _scrolls) {
|
||||
for (final scroll in _scrolls.values) {
|
||||
scroll.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A tap on the tab shown goes back to its top.
|
||||
void _tapped(int index) {
|
||||
final scroll = _scrolls[index];
|
||||
if (index == _active && scroll.hasClients) {
|
||||
/// Shows [part], kept for the next time; chosen again, it goes back to
|
||||
/// its top.
|
||||
void _show(StudysetPart part) {
|
||||
final scroll = _scrolls[part]!;
|
||||
if (_settled && part == _part && scroll.hasClients) {
|
||||
scroll.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
}
|
||||
|
||||
Future<void> _readWhatItHolds() async {
|
||||
final person = PersonScope.of(context);
|
||||
try {
|
||||
final cards = await person.cards.cardsOf(widget.studysetId);
|
||||
final notes = await person.notes.countsByStudyset();
|
||||
final pdfs = await person.pdfSources.countsByStudyset();
|
||||
_holds = (
|
||||
cards: cards.isNotEmpty,
|
||||
notes: notes.containsKey(widget.studysetId),
|
||||
pdfs: pdfs.containsKey(widget.studysetId),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'Reading what a studyset holds failed',
|
||||
name: 'Studyset',
|
||||
error: error,
|
||||
);
|
||||
if (mounted && !_settled) {
|
||||
_show(StudysetTab.flashcards, opening: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_chooseOnOpening();
|
||||
}
|
||||
|
||||
/// Shows [tab]: at once while [opening], sliding over otherwise.
|
||||
void _show(StudysetTab tab, {bool opening = false}) {
|
||||
_settle();
|
||||
if (_tabs.index != tab.index) {
|
||||
if (opening) {
|
||||
_tabs.index = tab.index;
|
||||
} else {
|
||||
_tabs.animateTo(tab.index);
|
||||
}
|
||||
} else {
|
||||
unawaited(_kept.keep(widget.studysetId, tab.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the tab to open on, once enough is known: paths in progress
|
||||
/// win at once; the others once both are known.
|
||||
void _chooseOnOpening() {
|
||||
/// Chooses the part to open on, once the paths are known: the plan while
|
||||
/// one is in progress, else the material.
|
||||
void _chooseOnOpening(PathListState paths) {
|
||||
if (_settled || !mounted) {
|
||||
return;
|
||||
}
|
||||
final paths = context.read<PathListBloc>().state;
|
||||
// Paths that can't be read are as good as none.
|
||||
final pathsKnown = paths.completionKnown || paths.failed;
|
||||
final StudysetPart part;
|
||||
if (paths.completionKnown && paths.hasOpenPaths) {
|
||||
_show(StudysetTab.paths, opening: true);
|
||||
} else if (pathsKnown && _holds != null) {
|
||||
_show(switch (_holds!) {
|
||||
(cards: false, notes: true, pdfs: _) => StudysetTab.notes,
|
||||
(cards: false, notes: false, pdfs: true) => StudysetTab.files,
|
||||
_ => StudysetTab.flashcards,
|
||||
}, opening: true);
|
||||
part = StudysetPart.plan;
|
||||
} else if (paths.completionKnown || paths.failed) {
|
||||
part = StudysetPart.material;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
}
|
||||
|
||||
/// The tab [tab], scrolling through [list], the slivers of its list: rows
|
||||
/// are built as they scroll into view. Pulled down, it syncs.
|
||||
Widget _tab(StudysetTab tab, Widget list) => RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
child: CustomScrollView(
|
||||
controller: _scrolls[tab.index],
|
||||
// Pullable however little it holds.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [list],
|
||||
),
|
||||
);
|
||||
|
||||
/// Around a tab's list; room below for the bar floating over it.
|
||||
static const _listPadding = EdgeInsets.fromLTRB(0, 8, 0, 96);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ContentTabCubit, ContentTabState>(
|
||||
listener: (context, request) {
|
||||
if (request != null) {
|
||||
_show(request.tab);
|
||||
final person = PersonScope.of(context);
|
||||
return BlocProvider(
|
||||
create: (context) => StudysetActivityCubit(
|
||||
person.cards,
|
||||
person.reviews,
|
||||
subtreeOf(widget.studysetId, widget.all),
|
||||
),
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ContentTabCubit, ContentTabState>(
|
||||
listener: (context, request) {
|
||||
if (request != null) {
|
||||
_show(request.tab.part);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) => _chooseOnOpening(state),
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (!_settled) {
|
||||
// The paths may be known already.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _chooseOnOpening(context.read<PathListBloc>().state),
|
||||
);
|
||||
}
|
||||
// Progress once anything in it was studied: a card, or a path
|
||||
// finished.
|
||||
final studied =
|
||||
(context.watch<StudysetActivityCubit>().state?.studied ??
|
||||
false) ||
|
||||
context.select<PathListBloc, bool>(
|
||||
(bloc) => bloc.state.completed.isNotEmpty,
|
||||
);
|
||||
final parts = [
|
||||
StudysetPart.material,
|
||||
if (studied) StudysetPart.progress,
|
||||
StudysetPart.plan,
|
||||
];
|
||||
return widget.builder(context, (
|
||||
settled: _settled,
|
||||
parts: parts,
|
||||
shown: parts.contains(_part) ? _part : StudysetPart.material,
|
||||
studied: studied,
|
||||
scrolls: _scrolls,
|
||||
show: _show,
|
||||
));
|
||||
},
|
||||
),
|
||||
BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) {
|
||||
_chooseOnOpening();
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingPaths && state.paths.length < 2) {
|
||||
setState(() => _reorderingPaths = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the studyset [parts] shows; a spinner until that is
|
||||
/// settled.
|
||||
class const _Content({
|
||||
required final String studysetId,
|
||||
required final _PartsView parts,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!parts.settled) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
// Buttons say what they do where there is room.
|
||||
final labelled = MediaQuery.sizeOf(context).width >= 480;
|
||||
return IndexedStack(
|
||||
index: parts.shown.index,
|
||||
children: [
|
||||
MaterialPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.material]!,
|
||||
labelled: labelled,
|
||||
),
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingNotes && state.notes.length < 2) {
|
||||
setState(() => _reorderingNotes = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PdfListBloc, PdfListState>(
|
||||
listener: (context, state) {
|
||||
if (_reorderingFiles && state.rows.length < 2) {
|
||||
setState(() => _reorderingFiles = false);
|
||||
}
|
||||
},
|
||||
if (parts.studied)
|
||||
ProgressPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.progress]!,
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
PlanPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.plan]!,
|
||||
labelled: labelled,
|
||||
),
|
||||
],
|
||||
child: !_settled
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
onTap: _tapped,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// The line below runs the card's whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
for (final tab in StudysetTab.values)
|
||||
Tab(
|
||||
text: switch (tab) {
|
||||
StudysetTab.paths => l10n.studysetTabPaths,
|
||||
StudysetTab.flashcards =>
|
||||
l10n.studysetTabFlashcards,
|
||||
StudysetTab.notes => l10n.studysetTabNotes,
|
||||
StudysetTab.files => l10n.studysetTabFiles,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _tabs,
|
||||
builder: (context, _) => switch (StudysetTab
|
||||
.values[_tabs.index]) {
|
||||
StudysetTab.flashcards => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Browse(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
),
|
||||
const CardListToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
StudysetTab.paths =>
|
||||
BlocSelector<PathListBloc, PathListState, int>(
|
||||
selector: (state) => state.paths.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingPaths,
|
||||
// Below that, an icon alone.
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingPaths = !_reorderingPaths,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.notes =>
|
||||
BlocSelector<NoteListBloc, NoteListState, int>(
|
||||
selector: (state) => state.notes.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingNotes,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingNotes = !_reorderingNotes,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.files =>
|
||||
BlocSelector<PdfListBloc, PdfListState, int>(
|
||||
selector: (state) => state.rows.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingFiles,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_tab(
|
||||
StudysetTab.paths,
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingPaths,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.flashcards,
|
||||
SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.notes,
|
||||
SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.files,
|
||||
SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse beside the flashcards' tab, while it has cards: opens the card
|
||||
/// browser of the studyset [studysetId], over everything inside it too
|
||||
/// when there is something. [labelled] says Browse, else an icon alone.
|
||||
class const _Browse({
|
||||
required final String studysetId,
|
||||
required final bool labelled,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCards = context.select<CardListBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
CardListStateLoaded(:final cards) => cards.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
if (!hasCards) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void open() => context.push(
|
||||
RoutePaths.cardBrowser(
|
||||
studysetId,
|
||||
// The rail's list: the studysets inside this one.
|
||||
subtree: switch (context.read<StudysetListBloc>().state) {
|
||||
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
),
|
||||
);
|
||||
return labelled
|
||||
? TextButton.icon(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid, size: 18),
|
||||
label: Text(l10n.cardBrowserBrowse),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid),
|
||||
tooltip: l10n.cardBrowserBrowseTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beside the tabs while a list that can be reordered shows, once it has
|
||||
/// [count] two or more: Reorder, and Done while [reordering]; [labelled]
|
||||
/// with its word, else an icon alone whose [tooltip] says what it reorders.
|
||||
class const _ReorderBeside({
|
||||
required final int count,
|
||||
required final bool reordering,
|
||||
required final bool labelled,
|
||||
required final String tooltip,
|
||||
required final VoidCallback onToggle,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (count < 2 && !reordering) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: ReorderToggle(
|
||||
reordering: reordering,
|
||||
labelled: labelled,
|
||||
tooltip: tooltip,
|
||||
onToggle: onToggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,14 +143,13 @@ void main() {
|
|||
expect(find.textContaining('Japanese'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets("on $window, a long studyset's screen, its tabs and topics", (
|
||||
testWidgets("on $window, a long studyset's screen, its parts and topics", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, RoutePaths.studyset('grammar'));
|
||||
|
||||
for (final tab in ['Paths', 'Flashcards', 'Notes']) {
|
||||
await tester.tap(find.text(tab).first);
|
||||
await tester.pumpAndSettle();
|
||||
for (final part in ['Study plan', 'Material']) {
|
||||
await openPart(tester, part);
|
||||
}
|
||||
if (width < 840) {
|
||||
await tester.tap(find.byTooltip('Show topics'));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -33,11 +34,6 @@ void main() {
|
|||
noteGateway = FakeNoteGateway();
|
||||
});
|
||||
|
||||
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||
/// 2 notes.
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
/// A note in the studyset, so that it opens on its notes.
|
||||
Future<void> withNote() async {
|
||||
final notes = NoteRepository(noteGateway);
|
||||
|
|
@ -206,31 +202,33 @@ void main() {
|
|||
expect(await repository.all(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('shows the flashcards after a deck came into this studyset, '
|
||||
'whatever tab was open', (tester) async {
|
||||
await withNote();
|
||||
await startImport(tester, fixture('anki_latest.apkg'));
|
||||
testWidgets('shows the material after a deck came into this studyset, '
|
||||
'whatever part was open', (tester) async {
|
||||
files.next = (name: 'japanese.apkg', bytes: fixture('anki_latest.apkg'));
|
||||
await showStudyset(tester);
|
||||
await openPart(tester, 'Study plan');
|
||||
await chooseImport(tester);
|
||||
await tester.tap(find.text('Tag cards by subdeck'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 1);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('leaves the tab as it was when the subdecks became '
|
||||
testWidgets('leaves the part as it was when the subdecks became '
|
||||
'sub-studysets', (tester) async {
|
||||
await withNote();
|
||||
files.next = (name: 'japanese.apkg', bytes: fixture('anki_latest.apkg'));
|
||||
await showStudyset(tester);
|
||||
expect(shownTab(tester), 2);
|
||||
await openPart(tester, 'Study plan');
|
||||
await chooseImport(tester);
|
||||
await tester.tap(find.text('Recreate as sub-studysets'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
});
|
||||
|
||||
testWidgets('warns when the files may not fit in the room left', (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -49,11 +50,6 @@ void main() {
|
|||
noteGateway = FakeNoteGateway();
|
||||
});
|
||||
|
||||
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||
/// 2 notes.
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
Future<void> showApp(
|
||||
WidgetTester tester,
|
||||
String location, {
|
||||
|
|
@ -120,8 +116,9 @@ void main() {
|
|||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a studyset opens again on the tab it was left on, here, '
|
||||
'even with a path unfinished', (tester) async {
|
||||
testWidgets('a studyset opens again on the part it was left on, here, '
|
||||
'even with a path unfinished; a tab kept before there were parts '
|
||||
'counts as its part', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
|
|
@ -135,10 +132,10 @@ void main() {
|
|||
tabs: MemoryStudysetTabStore({'japanese': 'notes'}),
|
||||
);
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('the tab a studyset shows, chosen on opening or by the '
|
||||
testWidgets('the part a studyset shows, chosen on opening or by the '
|
||||
'person, is kept for the next time', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
|
|
@ -149,12 +146,11 @@ void main() {
|
|||
final tabs = MemoryStudysetTabStore();
|
||||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'), tabs: tabs);
|
||||
expect(shownTab(tester), 0);
|
||||
expect(tabs.kept, {'japanese': 'paths'});
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(tabs.kept, {'japanese': 'plan'});
|
||||
|
||||
await tester.tap(find.widgetWithText(Tab, 'Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tabs.kept, {'japanese': 'notes'});
|
||||
await openPart(tester, 'Material');
|
||||
expect(tabs.kept, {'japanese': 'material'});
|
||||
});
|
||||
|
||||
testWidgets('a studyset with only notes opens on them', (tester) async {
|
||||
|
|
@ -167,7 +163,7 @@ void main() {
|
|||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -187,7 +183,7 @@ void main() {
|
|||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(shownTab(tester), 1);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('without paths, the studyset opens on its flashcards', (
|
||||
|
|
@ -196,8 +192,7 @@ void main() {
|
|||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
await tester.tap(find.text('Paths'));
|
||||
await tester.pumpAndSettle();
|
||||
await openPart(tester, 'Study plan');
|
||||
expect(find.text('No paths yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -294,7 +289,7 @@ void main() {
|
|||
);
|
||||
expect(find.textContaining('1 block was left out'), findsOneWidget);
|
||||
// The paths show by themselves.
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -332,7 +327,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('an import shows the paths tab at once, and how far it has '
|
||||
testWidgets('an import shows the study plan at once, and how far it has '
|
||||
'come above the paths it adds', (tester) async {
|
||||
Uint8List path(String title) => utf8.encode(
|
||||
'{"path": {"title": "$title", "steps": ['
|
||||
|
|
@ -345,9 +340,7 @@ void main() {
|
|||
final hold = Completer<void>();
|
||||
pathGateway.holdSteps = hold;
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
await tester.tap(find.text('Flashcards').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(shownTab(tester), isNot(0));
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
|
|
@ -362,7 +355,7 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(
|
||||
find.text('Importing path 1 of 2: “State-of-being”…'),
|
||||
findsOneWidget,
|
||||
|
|
@ -380,8 +373,8 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('a note written from Add material shows in the Notes tab on '
|
||||
'coming back, as flashcards do in theirs', (tester) async {
|
||||
testWidgets('a note written from Add material shows in the material on '
|
||||
'coming back, as flashcards do', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
|
|
@ -389,7 +382,7 @@ void main() {
|
|||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -399,7 +392,7 @@ void main() {
|
|||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Topic or subject'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
|
|||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -92,9 +93,6 @@ void main() {
|
|||
await choose(tester, 'Upload a PDF');
|
||||
}
|
||||
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
testWidgets('a PDF uploaded from Add material shows in Files, titled by '
|
||||
'its name', (tester) async {
|
||||
await showApp(tester);
|
||||
|
|
@ -103,7 +101,7 @@ void main() {
|
|||
|
||||
expect(files.asked.single, ['pdf']);
|
||||
expect(find.text('Added "Grammar Guide".'), findsOneWidget);
|
||||
expect(shownTab(tester), 3);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Grammar Guide'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +161,7 @@ void main() {
|
|||
await showApp(tester);
|
||||
});
|
||||
|
||||
expect(shownTab(tester), 3);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.textContaining('240 pages'), findsOneWidget);
|
||||
expect(find.text('25% read'), findsOneWidget);
|
||||
expect(find.text('6/day to reach page 120 in 10 days'), findsOneWidget);
|
||||
|
|
@ -188,7 +186,7 @@ void main() {
|
|||
expect(top('b'), lessThan(top('c')));
|
||||
});
|
||||
|
||||
testWidgets('Reorder beside the tabs shows the custom order, whatever the '
|
||||
testWidgets('Reorder beside the files shows the custom order, whatever the '
|
||||
'files were sorted by, and drags a file elsewhere in it, until '
|
||||
'Done', (tester) async {
|
||||
final pdfs = repository();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
|
||||
setUp(() {
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
addTearDown(cards.dispose);
|
||||
addTearDown(reviews.dispose);
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
);
|
||||
|
||||
/// The first activity read for [studysetIds].
|
||||
Future<StudysetActivityState> read(List<String> studysetIds) async {
|
||||
final cubit = StudysetActivityCubit(cards, reviews, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
return (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
}
|
||||
|
||||
test('nothing studied yet: no studying, and no time this week', () async {
|
||||
await card('japanese');
|
||||
|
||||
final activity = await read(['japanese']);
|
||||
|
||||
expect(activity.studied, isFalse);
|
||||
expect(activity.week, List.filled(7, Duration.zero));
|
||||
});
|
||||
|
||||
test('sums each day of the last seven, today last, across the studyset '
|
||||
'and what is inside it; a fresh start is no studying', () async {
|
||||
final now = DateTime(2026, 10, 12, 15);
|
||||
final own = await card('japanese');
|
||||
final inTopic = await card('grammar');
|
||||
final elsewhere = await card('french');
|
||||
Future<void> review(
|
||||
String cardId,
|
||||
DateTime at,
|
||||
int minutes, {
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) => reviews.record(
|
||||
cardId: cardId,
|
||||
rating: Rating.good,
|
||||
reviewedAt: at,
|
||||
duration: Duration(minutes: minutes),
|
||||
mode: mode,
|
||||
);
|
||||
await review(own, now, 2);
|
||||
await review(inTopic, now.subtract(const Duration(hours: 2)), 1);
|
||||
await review(own, now.subtract(const Duration(days: 1)), 3);
|
||||
await review(own, now.subtract(const Duration(days: 6)), 4);
|
||||
// A week ago and more, another studyset's, a fresh start: none counts.
|
||||
await review(own, now.subtract(const Duration(days: 7)), 5);
|
||||
await review(elsewhere, now, 6);
|
||||
await review(own, now, 7, mode: StudyMode.reset);
|
||||
|
||||
final activity = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => read(['japanese', 'grammar']),
|
||||
);
|
||||
|
||||
expect(activity.studied, isTrue);
|
||||
expect(activity.week, [
|
||||
const Duration(minutes: 4),
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
const Duration(minutes: 3),
|
||||
const Duration(minutes: 3),
|
||||
]);
|
||||
});
|
||||
|
||||
test('only a fresh start is no studying', () async {
|
||||
final own = await card('japanese');
|
||||
await reviews.record(
|
||||
cardId: own,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 1),
|
||||
mode: StudyMode.reset,
|
||||
);
|
||||
|
||||
expect((await read(['japanese'])).studied, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
|
|
@ -19,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
|
|
@ -26,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -597,55 +602,59 @@ void main() {
|
|||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('every empty tab shows its icon, that there is nothing yet, '
|
||||
'and how to add some', (tester) async {
|
||||
testWidgets('every empty section shows its icon, that there is nothing '
|
||||
'yet, and how to add some', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
for (final (tab, icon, title, how) in [
|
||||
for (final (part, icon, title, how) in [
|
||||
(
|
||||
'Flashcards',
|
||||
'Material',
|
||||
LucideIcons.layers,
|
||||
'No flashcards yet',
|
||||
'"Add material → Create flashcards"',
|
||||
),
|
||||
(
|
||||
'Paths',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
(
|
||||
'Notes',
|
||||
'Material',
|
||||
LucideIcons.notebookPen,
|
||||
'No notes yet',
|
||||
'"Add material → Create a note"',
|
||||
),
|
||||
(
|
||||
'Files',
|
||||
'Material',
|
||||
LucideIcons.folder,
|
||||
'No files yet',
|
||||
'"Add material → Upload a PDF"',
|
||||
),
|
||||
(
|
||||
'Study plan',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
]) {
|
||||
await tester.tap(find.text(tab));
|
||||
await tester.pumpAndSettle();
|
||||
final message = find.byType(EmptyListMessage);
|
||||
expect(message, findsOneWidget, reason: tab);
|
||||
await openPart(tester, part);
|
||||
// The box that says so, around its icon, title and hint.
|
||||
final box = find.ancestor(
|
||||
of: find.text(title),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is EmptyListMessage ||
|
||||
(widget is DecoratedBox &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration as BoxDecoration).border != null),
|
||||
),
|
||||
);
|
||||
expect(box, findsOneWidget, reason: title);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.byIcon(icon)),
|
||||
find.descendant(of: box, matching: find.byIcon(icon)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.text(title)),
|
||||
find.descendant(of: box, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -868,7 +877,7 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
testWidgets('with two notes or more, Reorder beside the tabs lets them be '
|
||||
testWidgets('with two notes or more, Reorder beside them lets them be '
|
||||
'dragged into another order, until Done', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final notes = NoteRepository(noteGateway);
|
||||
|
|
@ -880,7 +889,7 @@ void main() {
|
|||
);
|
||||
}
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
// It opens on the notes, its only material.
|
||||
// It opens on the material, the notes among it.
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Reorder'));
|
||||
|
|
@ -1485,7 +1494,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('tapping the open tab again goes back to its top', (
|
||||
testWidgets('tapping the open part again goes back to its top', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
|
|
@ -1501,12 +1510,15 @@ void main() {
|
|||
// On screen, not merely built.
|
||||
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();
|
||||
|
||||
await tester.drag(find.text('Note 3'), const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(first, findsNothing);
|
||||
await tester.tap(find.text('Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(first, findsOneWidget);
|
||||
});
|
||||
|
|
@ -1574,4 +1586,106 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// The parts the studyset screen offers, by their names.
|
||||
List<String> parts(WidgetTester tester) => [
|
||||
for (final part
|
||||
in tester
|
||||
.widget<StudysetPartSwitch>(find.byType(StudysetPartSwitch))
|
||||
.parts)
|
||||
studysetPartName(
|
||||
AppLocalizations.of(tester.element(find.byType(StudysetScreen))),
|
||||
part,
|
||||
),
|
||||
];
|
||||
|
||||
testWidgets('the parts are switched in the header: on a phone under the '
|
||||
"title, above the path; on a desktop at the end of the path's line", (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
width: 400,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
Rect switchRect() => tester.getRect(find.byType(StudysetPartSwitch));
|
||||
Rect pathRect() => tester.getRect(find.byType(StudysetBreadcrumb));
|
||||
expect(switchRect().bottom, lessThanOrEqualTo(pathRect().top));
|
||||
expect(switchRect().width, greaterThan(300));
|
||||
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.byType(StudysetPartSwitch),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The path's line stays as high as its steps.
|
||||
expect(pathRect().height, StudysetBreadcrumb.minTarget);
|
||||
expect(switchRect().right, greaterThan(pathRect().right - 16));
|
||||
});
|
||||
|
||||
testWidgets('Progress shows once a card in the studyset was studied: how '
|
||||
'much is learnt, what is due, and the study time this week', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final id = await card('japanese', '猫');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Study plan']);
|
||||
|
||||
await PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
).reviews.record(
|
||||
cardId: id,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 2),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Progress', 'Study plan']);
|
||||
|
||||
await openPart(tester, 'Progress');
|
||||
expect(find.text('Learned'), findsOneWidget);
|
||||
expect(find.text('0 of 1 card'), findsOneWidget);
|
||||
expect(find.text('To review'), findsOneWidget);
|
||||
expect(find.text('2 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('choosing another part ticks, as the navigation bar does; '
|
||||
'the one shown again does not', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final haptics = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
haptics.add(call.arguments);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
await openPart(tester, 'Material');
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(haptics, [
|
||||
'HapticFeedbackType.selectionClick',
|
||||
'HapticFeedbackType.selectionClick',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Pumps the whole app as a person starts it, on [dependencies] (usually
|
||||
|
|
@ -43,3 +45,19 @@ Future<void> openTab(WidgetTester tester, String label) async {
|
|||
await tester.tap(tab(label));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The part of the studyset screen shown.
|
||||
StudysetPart shownPart(WidgetTester tester) =>
|
||||
tester.widget<StudysetPartSwitch>(find.byType(StudysetPartSwitch)).current;
|
||||
|
||||
/// Shows the studyset screen's part [label] ("Material", "Study plan") and
|
||||
/// settles.
|
||||
Future<void> openPart(WidgetTester tester, String label) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetPartSwitch),
|
||||
matching: find.text(label),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue