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:
parent
eee3ff1c5d
commit
f9c1f2ae97
1 changed files with 921 additions and 210 deletions
|
|
@ -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 '
|
||||
|
|
|
|||
Loading…
Reference in a new issue