feat(studyset): the screens behind a studyset are its path

Deeper studysets, on iOS and Android alike (decided 2026-10-11, after
Material 3's and Apple's guidelines, Android's principles of navigation and
Nielsen Norman Group's on breadcrumbs):

- The screens behind a studyset are its path: below "Japanese › Grammar ›
  Tae Kim's Grammar Guide" lie Grammar's screen, then Japanese's, then the
  library. A topic at any depth opens with the levels between pushed
  beneath it; the path's steps go up by popping to that level's page (they
  replaced the location before, so back could skip levels); a studyset
  opened from elsewhere gets its levels beneath it. Back, Android's
  predictive back, iOS's swipe from the edge and the path all lead one
  level up.
- The path's steps are targets 48 dp high; a long press on back lists the
  levels above, the nearest first and the library last, as iOS's back
  buttons do.
- Below expanded windows the topics open in a sheet from "3 topics ▾" at
  the end of the path's line, where Vaia puts "All topics"; no drawer,
  whose swipe from the edge is iOS's way back and which Material 3
  Expressive deprecates. Topics with topics of their own unfold them, at
  any depth, in the sheet and the side column alike.
- The navigation bar steps aside inside a studyset (its screen, notes,
  paths and dictionary): Material 3 puts it on the top-level screens and
  toolbars below, never both. Apple's guidelines keep the tab bar, lest
  people forget where they are; the path, starting at Library, says where.
  A strip as high as the system's bar keeps those screens clear of it.
- The bar at the bottom keeps Add material and Study.

A trial for review, on its own branch, on top of the floating navigation
bar.

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 13:03:27 +02:00
commit f9c1f2ae97
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -2,6 +2,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.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';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
@ -22,7 +23,9 @@ import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_opt
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.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: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_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -445,7 +448,7 @@ void main() {
});
testWidgets('the bibliography is a button at the foot of the topics, not '
'in the app bar: in the side bar, and on a phone in the drawer', (
"in the app bar: in the side bar, and on a phone in the topics' sheet", (
tester,
) async {
stored('japanese', 'Japanese');
@ -475,7 +478,7 @@ void main() {
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(Drawer),
of: find.byType(BottomSheet),
matching: find.byTooltip('Bibliography'),
),
findsOneWidget,
@ -554,7 +557,7 @@ void main() {
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
final drawer = tester.getRect(find.byType(Drawer));
final sheet = tester.getRect(find.byType(StudysetRail));
final create = tester.getRect(
find.ancestor(
of: find.text('New sub-studyset'),
@ -563,8 +566,8 @@ void main() {
);
final bibliography = tester.getRect(find.byTooltip('Bibliography'));
expect(bibliography.center.dy, moreOrLessEquals(create.center.dy));
expect(create.left, moreOrLessEquals(drawer.left + 16));
expect(bibliography.right, moreOrLessEquals(drawer.right - 16));
expect(create.left, moreOrLessEquals(sheet.left + 16));
expect(bibliography.right, moreOrLessEquals(sheet.right - 16));
});
testWidgets('with two paths or more, Reorder beside the tabs lets them be '
@ -1179,10 +1182,12 @@ void main() {
);
});
testWidgets('below expanded windows the topics are in a drawer, opened '
'from the bar at the bottom, and the back button stays', (tester) async {
testWidgets('below expanded windows the topics open in a sheet from the '
"end of the path's line, which says how many there are; the back "
'button stays', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
stored('kanji', 'Kanji', parent: 'japanese', position: 'a1');
tester.view.physicalSize = const Size(600, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@ -1194,13 +1199,268 @@ void main() {
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.byTooltip('Back'), findsOneWidget);
expect(find.byType(Drawer), findsNothing);
expect(find.text('Kana'), findsNothing);
expect(
tester.getCenter(find.text('2 topics')).dy,
moreOrLessEquals(
tester
.getCenter(
find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.text('Library'),
),
)
.dy,
),
);
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
expect(find.text('Topics'), findsOneWidget);
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.text('Kana'), findsOneWidget);
expect(find.text('Kanji'), findsOneWidget);
});
testWidgets('on a phone, Add material and Study float side by side at the '
'bottom, centred', (tester) async {
stored('japanese', 'Japanese');
await card('japanese', '日本');
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 400,
);
final add = tester.getCenter(find.byTooltip('Add material'));
final study = tester.getCenter(find.text('Learn 1'));
expect(add.dy, greaterThan(1000 * 0.85));
expect(study.dy, moreOrLessEquals(add.dy, epsilon: 1));
expect((add.dx + study.dx) / 2, moreOrLessEquals(200, epsilon: 40));
});
testWidgets('without topics, the button says Topics, and its sheet makes '
'the first', (tester) async {
stored('japanese', 'Japanese');
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 400,
);
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
expect(find.text('No sub-studysets yet.'), findsOneWidget);
expect(find.text('New sub-studyset'), findsOneWidget);
});
group('the screens behind a studyset are its path', () {
/// Japanese › Grammar › Tae Kim's Grammar Guide, beside Kana.
void tree() {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
stored('grammar', 'Grammar', parent: 'japanese', position: 'a1');
stored('tae-kim', "Tae Kim's Grammar Guide", parent: 'grammar');
}
/// Opens Japanese from the library on a phone.
Future<void> openJapanese(WidgetTester tester) async {
await showApp(tester, width: 400);
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
}
/// Opens [title] from the topics' sheet, unfolding [unfold] first.
Future<void> openTopic(
WidgetTester tester,
String title, {
List<String> unfold = const [],
}) async {
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
for (final _ in unfold) {
await tester.tap(find.byTooltip('Show the topics inside').first);
await tester.pumpAndSettle();
}
await tester.tap(find.text(title));
await tester.pumpAndSettle();
}
/// The title in the app bar.
Finder titled(String title) =>
find.descendant(of: find.byType(AppBar), matching: find.text(title));
testWidgets('a topic opens over the studyset, and back leads one level '
'up, then to the library', (tester) async {
tree();
await openJapanese(tester);
await openTopic(tester, 'Grammar');
expect(titled('Grammar'), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
expect(titled('Japanese'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(LibraryScreen), findsOneWidget);
});
testWidgets('a topic deep inside opens from the sheet with the levels '
'between beneath it', (tester) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
expect(titled("Tae Kim's Grammar Guide"), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(titled('Grammar'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(titled('Japanese'), findsOneWidget);
});
testWidgets('a step of the path goes back up to it, and back from there '
'goes on up; on a phone, through the "…" the steps fold into', (
tester,
) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
await tester.tap(find.text('…'));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(MenuItemButton),
matching: find.text('Japanese'),
),
);
await tester.pumpAndSettle();
expect(titled('Japanese'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(LibraryScreen), findsOneWidget);
});
testWidgets("the path's steps and its fold are targets 48 high", (
tester,
) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
Size targetOf(String text) => tester.getSize(
find
.ancestor(
of: find.descendant(
of: find.byType(StudysetBreadcrumb),
matching: find.text(text),
),
matching: find.byType(GestureDetector),
)
.first,
);
expect(targetOf('…'), const Size.square(StudysetBreadcrumb.minTarget));
// Wide enough for the whole path.
await openFromLibrary(tester, 'Japanese');
expect(targetOf('Library').height, StudysetBreadcrumb.minTarget);
});
testWidgets('holding back lists the levels above, the nearest first and '
'the library last; choosing one goes up there', (tester) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
await tester.longPress(find.byTooltip('Back'));
await tester.pumpAndSettle();
final items = [
for (final title in ['Grammar', 'Japanese', 'Library'])
tester
.getRect(
find.descendant(
of: find.byType(MenuItemButton),
matching: find.text(title),
),
)
.top,
];
expect(items, orderedEquals([...items]..sort()));
await tester.tap(
find.descendant(
of: find.byType(MenuItemButton),
matching: find.text('Japanese'),
),
);
await tester.pumpAndSettle();
expect(titled('Japanese'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(LibraryScreen), findsOneWidget);
});
testWidgets('a studyset opened while its levels are not beneath gets '
'them', (tester) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
final context = tester.element(find.byType(StudysetScreen).last);
final all = await PersonScope.of(context).studysets.all();
// From the library tab's first screen, as from elsewhere.
openStudyset(context, 'tae-kim', all);
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(titled('Grammar'), findsOneWidget);
});
testWidgets('deleting the studyset shown goes up to the one it sat in', (
tester,
) async {
tree();
await openJapanese(tester);
await openTopic(
tester,
"Tae Kim's Grammar Guide",
unfold: const ['Grammar'],
);
await choose(tester, 'Delete');
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
await tester.pumpAndSettle();
expect(titled('Grammar'), findsOneWidget);
});
});
testWidgets('a long description is cut short, the whole of it a tap on '