feat(studyset): a studyset opens on the tab it was left on
All checks were successful
CI / check (push) Successful in 14m17s
CI / deploy (push) Successful in 18s

A studyset opened on its flashcards and slid over to its paths once it
had worked out that one was unfinished, every time it was opened. It now
opens on the tab it was left on, on this device: kept in the person's
folder (studyset_tabs.json), read as their data opens, so at once. The
first time a studyset opens here it still chooses by what it holds
(paths in progress, then flashcards, notes, files), but shows that it is
loading until it knows, rather than one tab and then another; paths that
can't be read count as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 23:30:47 +02:00
commit 6b1ff4122e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 393 additions and 145 deletions

View file

@ -98,6 +98,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*). - **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`): the tab screens run on beneath it (the shell's `extendBody`), every list ending with room for it (the body's bottom padding) and floating buttons lifted by `FloatingNavigationBar.coverOf`, with the background of the screen in view around it in between (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone. - **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; `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). - **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). - **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. - **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.
- **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. - **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.

View file

@ -74,6 +74,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -364,6 +365,10 @@ Future<PersonDependencies> _openPerson(
ServerpodPublishGateway(client), ServerpodPublishGateway(client),
FilePublishedStore(File(p.join(folder.path, 'published.json'))), FilePublishedStore(File(p.join(folder.path, 'published.json'))),
); );
final studysetTabs = StudysetTabs(
FileStudysetTabStore(File(p.join(folder.path, 'studyset_tabs.json'))),
);
await studysetTabs.open();
return PersonDependencies( return PersonDependencies(
sync: sync, sync: sync,
@ -386,6 +391,7 @@ Future<PersonDependencies> _openPerson(
pomodoroRuns: FilePomodoroRunStore( pomodoroRuns: FilePomodoroRunStore(
File(p.join(folder.path, 'pomodoro.json')), File(p.join(folder.path, 'pomodoro.json')),
), ),
studysetTabs: studysetTabs,
profile: ProfileRepository(ServerpodProfileGateway(client)), profile: ProfileRepository(ServerpodProfileGateway(client)),
publishing: publishing, publishing: publishing,
explore: ExploreRepository( explore: ExploreRepository(

View file

@ -18,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// Everything that belongs to one signed-in person: their local database, its /// Everything that belongs to one signed-in person: their local database, its
@ -80,6 +81,9 @@ class PersonDependencies({
/// Where this device keeps the person's Pomodoro timer. /// Where this device keeps the person's Pomodoro timer.
required final PomodoroRunStore pomodoroRuns, required final PomodoroRunStore pomodoroRuns,
/// The tab each of the person's studysets was left on, on this device.
required final StudysetTabs studysetTabs,
/// The person's public profile, on the instance. /// The person's public profile, on the instance.
required final ProfileRepository profile, required final ProfileRepository profile,

View file

@ -0,0 +1,78 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
/// Where the device keeps the tab each of the person's studysets was left
/// on.
abstract interface class StudysetTabStore() {
/// The tabs kept, by studyset id; none when nothing is or it can't be
/// read.
Future<Map<String, String>> read();
/// Keeps [tabs].
Future<void> write(Map<String, String> tabs);
}
/// Keeps them as a JSON object in [_file], in the person's folder, which
/// goes with them; written to a temporary file first and renamed into
/// place.
class FileStudysetTabStore(final File _file) implements StudysetTabStore {
// One write at a time: they share the temporary file.
final _queue = SerialQueue();
@override
Future<Map<String, String>> read() async {
try {
final json = jsonDecode(await _file.readAsString()) as Map;
return {
for (final MapEntry(:key, :value) in json.entries)
if ((key, value) case (final String id, final String tab)) id: tab,
};
} on FileSystemException {
return const {};
} on Object catch (error) {
log(
"The studysets' tabs are unreadable",
name: 'Studyset',
error: error,
);
return const {};
}
}
@override
Future<void> write(Map<String, String> tabs) => _queue.run(() async {
await _file.parent.create(recursive: true);
final temporary = File('${_file.path}.new');
await temporary.writeAsString(jsonEncode(tabs), flush: true);
await temporary.rename(_file.path);
});
}
/// The tab each of the person's studysets was left on, on this device
/// (decided 2026-10-11), so a studyset opens again where it was left rather
/// than choosing anew each time: read once as the person's data opens,
/// kept as tabs are shown. A tab is kept by its name; one the app no longer
/// has reads as none.
class StudysetTabs(final StudysetTabStore _store) {
var _tabs = <String, String>{};
/// Reads the tabs kept, before any studyset opens.
Future<void> open() async => _tabs = {...await _store.read()};
/// The tab the studyset [studysetId] was left on; null if it never opened
/// here.
String? of(String studysetId) => _tabs[studysetId];
/// Keeps [tab] as the one the studyset [studysetId] was left on; done once
/// it is written.
Future<void> keep(String studysetId, String tab) async {
if (_tabs[studysetId] == tab) {
return;
}
_tabs = {..._tabs, studysetId: tab};
await _store.write(_tabs);
}
}

View file

@ -19,6 +19,7 @@ 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/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.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/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/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart'; import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
@ -54,6 +55,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dar
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.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/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
@ -605,12 +607,15 @@ class const _BibliographyButton({required final Studyset studyset})
/// narrowing them to a tag and managing tags, on the paths reordering them /// narrowing them to a tag and managing tags, on the paths reordering them
/// once there are two. /// once there are two.
/// ///
/// It opens on the first tab with something of the studyset's own in it /// It opens on the tab it was left on, on this device ([StudysetTabs],
/// (its topics' content is in theirs), in their order: paths not completed /// decided 2026-10-11). Opened here for the first time, it opens on the
/// yet, flashcards, notes, files; on the flashcards when there is nothing /// first tab with something of the studyset's own in it (its topics'
/// (decided 2026-10-03). Material added or imported shows in its own tab /// content is in theirs), in their order: paths not completed yet,
/// ([ContentTabCubit]). Once the person chose a tab, or material was added, /// flashcards, notes, files; on the flashcards when there is nothing
/// opening no longer moves it. /// (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}) class const _Content({required final String studysetId})
extends StatefulWidget { extends StatefulWidget {
@override @override
@ -623,11 +628,15 @@ class _ContentState()
late final _tabs = TabController( late final _tabs = TabController(
length: StudysetTab.values.length, length: StudysetTab.values.length,
vsync: this, vsync: this,
initialIndex: StudysetTab.flashcards.index, initialIndex: _active,
); );
/// Whether the tab shown is settled: chosen on opening, by the person, or /// The tab each studyset was left on, on this device.
/// for material added. 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
/// shows.
var _settled = false; var _settled = false;
/// Whether the paths show handles to drag them. /// Whether the paths show handles to drag them.
@ -652,14 +661,30 @@ class _ContentState()
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (StudysetTab.values.asNameMap()[_kept.of(widget.studysetId)]
case final tab?) {
_active = tab.index;
_settled = true;
}
_tabs.addListener(() { _tabs.addListener(() {
// Swiping to a tab is choosing it as much as tapping it is. // Swiping to a tab is choosing it as much as tapping it is.
_settled = true; _settle();
if (!_tabs.indexIsChanging) { if (!_tabs.indexIsChanging) {
_active = _tabs.index; _active = _tabs.index;
unawaited(
_kept.keep(widget.studysetId, StudysetTab.values[_active].name),
);
} }
}); });
unawaited(_readWhatItHolds()); if (!_settled) {
unawaited(_readWhatItHolds());
}
}
void _settle() {
if (!_settled && mounted) {
setState(() => _settled = true);
}
} }
@override @override
@ -695,21 +720,30 @@ class _ContentState()
pdfs: pdfs.containsKey(widget.studysetId), pdfs: pdfs.containsKey(widget.studysetId),
); );
} on Object catch (error) { } on Object catch (error) {
// It stays on the flashcards, as it opened.
log( log(
'Reading what a studyset holds failed', 'Reading what a studyset holds failed',
name: 'Studyset', name: 'Studyset',
error: error, error: error,
); );
if (mounted && !_settled) {
_show(StudysetTab.flashcards, opening: true);
}
return; return;
} }
_chooseOnOpening(); _chooseOnOpening();
} }
void _show(StudysetTab tab) { /// Shows [tab]: at once while [opening], sliding over otherwise.
_settled = true; void _show(StudysetTab tab, {bool opening = false}) {
_settle();
if (_tabs.index != tab.index) { if (_tabs.index != tab.index) {
_tabs.animateTo(tab.index); if (opening) {
_tabs.index = tab.index;
} else {
_tabs.animateTo(tab.index);
}
} else {
unawaited(_kept.keep(widget.studysetId, tab.name));
} }
} }
@ -720,14 +754,16 @@ class _ContentState()
return; return;
} }
final paths = context.read<PathListBloc>().state; final paths = context.read<PathListBloc>().state;
// Paths that can't be read are as good as none.
final pathsKnown = paths.completionKnown || paths.failed;
if (paths.completionKnown && paths.hasOpenPaths) { if (paths.completionKnown && paths.hasOpenPaths) {
_show(StudysetTab.paths); _show(StudysetTab.paths, opening: true);
} else if (paths.completionKnown && _holds != null) { } else if (pathsKnown && _holds != null) {
_show(switch (_holds!) { _show(switch (_holds!) {
(cards: false, notes: true, pdfs: _) => StudysetTab.notes, (cards: false, notes: true, pdfs: _) => StudysetTab.notes,
(cards: false, notes: false, pdfs: true) => StudysetTab.files, (cards: false, notes: false, pdfs: true) => StudysetTab.files,
_ => StudysetTab.flashcards, _ => StudysetTab.flashcards,
}); }, opening: true);
} }
} }
@ -783,148 +819,150 @@ class _ContentState()
}, },
), ),
], ],
child: Column( child: !_settled
crossAxisAlignment: CrossAxisAlignment.stretch, ? const Center(child: AppLoadingIndicator())
children: [ : Column(
LayoutBuilder( crossAxisAlignment: CrossAxisAlignment.stretch,
builder: (context, constraints) => Row(
children: [ children: [
Expanded( LayoutBuilder(
child: TabBar( builder: (context, constraints) => Row(
controller: _tabs, children: [
onTap: _tapped, Expanded(
isScrollable: true, child: TabBar(
tabAlignment: TabAlignment.start, controller: _tabs,
indicatorSize: TabBarIndicatorSize.label, onTap: _tapped,
// The line below runs the card's whole width instead. isScrollable: true,
dividerColor: Colors.transparent, tabAlignment: TabAlignment.start,
tabs: [ indicatorSize: TabBarIndicatorSize.label,
for (final tab in StudysetTab.values) // The line below runs the card's whole width instead.
Tab( dividerColor: Colors.transparent,
text: switch (tab) { tabs: [
StudysetTab.paths => l10n.studysetTabPaths, for (final tab in StudysetTab.values)
StudysetTab.flashcards => Tab(
l10n.studysetTabFlashcards, text: switch (tab) {
StudysetTab.notes => l10n.studysetTabNotes, StudysetTab.paths => l10n.studysetTabPaths,
StudysetTab.files => l10n.studysetTabFiles, 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,
);
},
),
),
},
),
], ],
), ),
), ),
AnimatedBuilder( const Divider(height: 1),
animation: _tabs, Expanded(
builder: (context, _) => child: TabBarView(
switch (StudysetTab.values[_tabs.index]) { controller: _tabs,
StudysetTab.flashcards => Padding( children: [
padding: const EdgeInsets.only(right: 16), _tab(
child: Row( StudysetTab.paths,
mainAxisSize: MainAxisSize.min, SliverPathList(
children: [ studysetId: widget.studysetId,
_Browse( reordering: _reorderingPaths,
studysetId: widget.studysetId, padding: _listPadding,
labelled: constraints.maxWidth >= 480,
),
const CardListToolbar(),
],
),
), ),
StudysetTab.paths => ),
BlocSelector<PathListBloc, PathListState, int>( _tab(
selector: (state) => state.paths.length, StudysetTab.flashcards,
builder: (context, count) => _ReorderBeside( SliverCardList(
count: count, cards: PersonScope.of(context).cards,
reordering: _reorderingPaths, studysetId: widget.studysetId,
// Below that, an icon alone. padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
labelled: constraints.maxWidth >= 480, ),
tooltip: l10n.studysetReorderPaths, ),
onToggle: () => setState( _tab(
() => _reorderingPaths = !_reorderingPaths, StudysetTab.notes,
), SliverNoteList(
), studysetId: widget.studysetId,
), reordering: _reorderingNotes,
StudysetTab.notes => padding: _listPadding,
BlocSelector<NoteListBloc, NoteListState, int>( ),
selector: (state) => state.notes.length, ),
builder: (context, count) => _ReorderBeside( _tab(
count: count, StudysetTab.files,
reordering: _reorderingNotes, SliverPdfList(
labelled: constraints.maxWidth >= 480, studysetId: widget.studysetId,
tooltip: l10n.studysetReorderNotes, reordering: _reorderingFiles,
onToggle: () => setState( padding: _listPadding,
() => _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,
), ),
), ),
], ],
), ),
),
],
),
); );
} }
} }

View file

@ -25,6 +25,7 @@ import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_reposit
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -46,6 +47,7 @@ import '../../support/fake_tag_gateway.dart';
import '../../support/memory_asset_file_cache.dart'; import '../../support/memory_asset_file_cache.dart';
import '../../support/memory_pomodoro_run_store.dart'; import '../../support/memory_pomodoro_run_store.dart';
import '../../support/memory_published_store.dart'; import '../../support/memory_published_store.dart';
import '../../support/memory_studyset_tab_store.dart';
import '../../support/test_dependencies.dart'; import '../../support/test_dependencies.dart';
/// A person's dependencies on fakes, closing with [onDispose]. /// A person's dependencies on fakes, closing with [onDispose].
@ -80,6 +82,7 @@ PersonDependencies personDependencies({Future<void> Function()? onDispose}) {
stepDeletions: StepDeletions(paths), stepDeletions: StepDeletions(paths),
pdfDeletions: PdfDeletions(pdfSources), pdfDeletions: PdfDeletions(pdfSources),
pomodoroRuns: MemoryPomodoroRunStore(), pomodoroRuns: MemoryPomodoroRunStore(),
studysetTabs: StudysetTabs(MemoryStudysetTabStore()),
profile: ProfileRepository(FakeProfileGateway()), profile: ProfileRepository(FakeProfileGateway()),
publishing: PublishRepository( publishing: PublishRepository(
FakePublishGateway(), FakePublishGateway(),

View file

@ -17,6 +17,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_
import 'package:halkyon_learn_flutter/src/feature/note/data/note_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/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -25,6 +26,7 @@ import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart'; import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_path_answer_gateway.dart'; import '../../../support/fake_path_answer_gateway.dart';
import '../../../support/fake_studyset_gateway.dart'; import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_studyset_tab_store.dart';
import '../../../support/pump_app.dart'; import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart'; import '../../../support/test_dependencies.dart';
@ -57,6 +59,7 @@ void main() {
String location, { String location, {
DeviceSettings deviceSettings = const DeviceSettings(), DeviceSettings deviceSettings = const DeviceSettings(),
Size size = const Size(1200, 1000), Size size = const Size(1200, 1000),
StudysetTabStore? tabs,
}) async { }) async {
tester.view.physicalSize = size; tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -70,6 +73,7 @@ void main() {
noteGateway: noteGateway, noteGateway: noteGateway,
files: files, files: files,
deviceSettings: deviceSettings, deviceSettings: deviceSettings,
studysetTabStore: tabs,
), ),
location: location, location: location,
); );
@ -116,6 +120,43 @@ void main() {
expect(find.text('Particles'), findsOneWidget); 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 {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(
tester,
RoutePaths.studyset('japanese'),
tabs: MemoryStudysetTabStore({'japanese': 'notes'}),
);
expect(shownTab(tester), 2);
});
testWidgets('the tab 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(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
final tabs = MemoryStudysetTabStore();
await showApp(tester, RoutePaths.studyset('japanese'), tabs: tabs);
expect(shownTab(tester), 0);
expect(tabs.kept, {'japanese': 'paths'});
await tester.tap(find.widgetWithText(Tab, 'Notes'));
await tester.pumpAndSettle();
expect(tabs.kept, {'japanese': 'notes'});
});
testWidgets('a studyset with only notes opens on them', (tester) async { testWidgets('a studyset with only notes opens on them', (tester) async {
final notes = NoteRepository(noteGateway); final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose); addTearDown(notes.dispose);

View file

@ -0,0 +1,53 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import '../../../support/memory_studyset_tab_store.dart';
void main() {
late Directory folder;
setUp(() async {
folder = await Directory.systemTemp.createTemp('studyset_tabs');
});
tearDown(() => folder.delete(recursive: true));
test('keeps the tab each studyset was left on, in a file that a new '
'session reads again', () async {
final file = File('${folder.path}/studyset_tabs.json');
final tabs = StudysetTabs(FileStudysetTabStore(file));
await tabs.open();
expect(tabs.of('japanese'), isNull);
await tabs.keep('japanese', 'paths');
await tabs.keep('spanish', 'notes');
await tabs.keep('japanese', 'flashcards');
final later = StudysetTabs(FileStudysetTabStore(file));
await later.open();
expect(later.of('japanese'), 'flashcards');
expect(later.of('spanish'), 'notes');
});
test('reads an unreadable file as no tabs kept', () async {
final file = File('${folder.path}/studyset_tabs.json')
..writeAsStringSync('{not json');
final tabs = StudysetTabs(FileStudysetTabStore(file));
await tabs.open();
expect(tabs.of('japanese'), isNull);
});
test('writes only when the tab changes', () async {
final kept = {'japanese': 'paths'};
final store = MemoryStudysetTabStore(kept);
final tabs = StudysetTabs(store);
await tabs.open();
await tabs.keep('japanese', 'paths');
expect(store.kept, same(kept));
await tabs.keep('japanese', 'notes');
expect(store.kept, {'japanese': 'notes'});
});
}

View file

@ -0,0 +1,16 @@
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
/// Keeps the studysets' tabs in memory.
class MemoryStudysetTabStore([Map<String, String> initial = const {}])
implements StudysetTabStore {
var _tabs = initial;
/// What is kept now.
Map<String, String> get kept => _tabs;
@override
Future<Map<String, String>> read() async => _tabs;
@override
Future<void> write(Map<String, String> tabs) async => _tabs = tabs;
}

View file

@ -69,6 +69,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.d
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
@ -101,6 +102,7 @@ import 'memory_asset_file_cache.dart';
import 'memory_device_settings_store.dart'; import 'memory_device_settings_store.dart';
import 'memory_pomodoro_run_store.dart'; import 'memory_pomodoro_run_store.dart';
import 'memory_published_store.dart'; import 'memory_published_store.dart';
import 'memory_studyset_tab_store.dart';
/// The app's dependencies with fakes instead of the server and the device's /// The app's dependencies with fakes instead of the server and the device's
/// database; each test passes in only the fakes it looks at. /// database; each test passes in only the fakes it looks at.
@ -147,6 +149,7 @@ Dependencies testDependencies({
ProfileGateway? profileGateway, ProfileGateway? profileGateway,
PublishGateway? publishGateway, PublishGateway? publishGateway,
PublishedStore? publishedStore, PublishedStore? publishedStore,
StudysetTabStore? studysetTabStore,
ExploreGateway? exploreGateway, ExploreGateway? exploreGateway,
FeedbackWriteGateway? feedbackWriteGateway, FeedbackWriteGateway? feedbackWriteGateway,
FeedbackServerGateway? feedbackServerGateway, FeedbackServerGateway? feedbackServerGateway,
@ -238,6 +241,10 @@ Dependencies testDependencies({
final pathDeletions = LearningPathDeletions(paths); final pathDeletions = LearningPathDeletions(paths);
final stepDeletions = StepDeletions(paths); final stepDeletions = StepDeletions(paths);
final pdfDeletions = PdfDeletions(pdfSources); final pdfDeletions = PdfDeletions(pdfSources);
final studysetTabs = StudysetTabs(
studysetTabStore ?? MemoryStudysetTabStore(),
);
await studysetTabs.open();
return PersonDependencies( return PersonDependencies(
sync: sync, sync: sync,
studysets: studysets, studysets: studysets,
@ -257,6 +264,7 @@ Dependencies testDependencies({
stepDeletions: stepDeletions, stepDeletions: stepDeletions,
pdfDeletions: pdfDeletions, pdfDeletions: pdfDeletions,
pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(), pomodoroRuns: pomodoroRuns ?? MemoryPomodoroRunStore(),
studysetTabs: studysetTabs,
// The picture as it is: drawing it anew is real work a widget // The picture as it is: drawing it anew is real work a widget
// test's fake clock can't wait for (avatar_image_test.dart has it). // test's fake clock can't wait for (avatar_image_test.dart has it).
profile: ProfileRepository( profile: ProfileRepository(