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
15 changed files with 921 additions and 210 deletions

View file

@ -286,6 +286,9 @@
"studysetReorderFiles": "Dateien umsortieren",
"studysetReorderDone": "Fertig",
"studysetOpenTopics": "Themen anzeigen",
"studysetTopicCount": "{count, plural, =1{1 Thema} other{{count} Themen}}",
"studysetShowInnerTopics": "Themen darin anzeigen",
"studysetHideInnerTopics": "Themen darin ausblenden",
"studysetTopics": "Themen",
"studysetAllTopics": "Alle Themen",
"studysetNewSubStudyset": "Neues Unter-Lernset",

View file

@ -317,6 +317,10 @@
"studysetReorderFiles": "Reorder files",
"studysetReorderDone": "Done",
"studysetOpenTopics": "Show topics",
"studysetTopicCount": "{count, plural, =1{1 topic} other{{count} topics}}",
"@studysetTopicCount": {"placeholders": {"count": {"type": "int"}}},
"studysetShowInnerTopics": "Show the topics inside",
"studysetHideInnerTopics": "Hide the topics inside",
"studysetTopics": "Topics",
"studysetAllTopics": "All topics",
"studysetNewSubStudyset": "New sub-studyset",

View file

@ -1774,6 +1774,24 @@ abstract class AppLocalizations {
/// **'Show topics'**
String get studysetOpenTopics;
/// No description provided for @studysetTopicCount.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 topic} other{{count} topics}}'**
String studysetTopicCount(int count);
/// No description provided for @studysetShowInnerTopics.
///
/// In en, this message translates to:
/// **'Show the topics inside'**
String get studysetShowInnerTopics;
/// No description provided for @studysetHideInnerTopics.
///
/// In en, this message translates to:
/// **'Hide the topics inside'**
String get studysetHideInnerTopics;
/// No description provided for @studysetTopics.
///
/// In en, this message translates to:

View file

@ -1059,6 +1059,23 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetOpenTopics => 'Themen anzeigen';
@override
String studysetTopicCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Themen',
one: '1 Thema',
);
return '$_temp0';
}
@override
String get studysetShowInnerTopics => 'Themen darin anzeigen';
@override
String get studysetHideInnerTopics => 'Themen darin ausblenden';
@override
String get studysetTopics => 'Themen';

View file

@ -1055,6 +1055,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetOpenTopics => 'Show topics';
@override
String studysetTopicCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count topics',
one: '1 topic',
);
return '$_temp0';
}
@override
String get studysetShowInnerTopics => 'Show the topics inside';
@override
String get studysetHideInnerTopics => 'Hide the topics inside';
@override
String get studysetTopics => 'Topics';

View file

@ -50,6 +50,9 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
StatefulShellRoute(
builder: (context, state, navigationShell) => AppShell(
navigationShell: navigationShell,
// Inside a studyset, its own bar at the bottom takes the
// navigation bar's place.
hidesNavigationBar: RoutePaths.isInsideStudyset(state.uri.path),
// The inbox's threads with something new.
profileBadge: (context) =>
context.select<InboxCountBloc?, int>((bloc) => bloc?.state ?? 0),
@ -76,11 +79,13 @@ GoRouter buildRouter({String initialLocation = RoutePaths.home}) => GoRouter(
GoRoute(
path: 'studysets/:id',
// Keyed by the id: several studysets' screens can be on
// the stack at once, and must not share one page.
// the stack at once, and must not share one page. Named by
// its location, so the way up finds it among them.
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return MaterialPage<void>(
key: ValueKey('studyset-$id'),
name: RoutePaths.studyset(id),
child: _studysetScreen(context, id),
);
},

View file

@ -31,6 +31,13 @@ class const AppShell({
/// How many things are new behind the Profile tab, for its badge; none
/// without.
final int Function(BuildContext context)? profileBadge,
/// Whether the navigation bar below medium windows steps aside, for a
/// screen with a bar of its own at the bottom: Material 3 puts the
/// navigation bar on the top-level screens and the actions of those below
/// in a toolbar, never both at once (decided 2026-10-11, as Vaia does). The
/// rail of wider windows stays.
final bool hidesNavigationBar = false,
super.key,
}) extends StatefulWidget {
@override
@ -113,7 +120,12 @@ class _AppShellState() extends State<AppShell> {
builder: (context, background, _) => Scaffold(
backgroundColor: background,
body: navigationShell,
bottomNavigationBar: bar,
// Without the bar, a strip as high as the system's own bar, in
// the screen's background, keeps every screen inside clear of
// it.
bottomNavigationBar: widget.hidesNavigationBar
? SizedBox(height: MediaQuery.paddingOf(context).bottom)
: bar,
),
),
),

View file

@ -22,10 +22,15 @@ abstract final class RoutePaths() {
/// The reading deadlines on a line of days, over the tabs.
static const readingTimeline = '/reading-timeline';
/// A studyset's screen, nested in the library tab: its back button leads
/// to the library.
/// A studyset's screen, nested in the library tab, above the screens of
/// the studysets it sits in.
static String studyset(String id) => '/library/studysets/$id';
/// Whether [path] is a studyset's screen or one of its own inside the
/// tabs: its notes, paths and dictionary.
static bool isInsideStudyset(String path) =>
path.startsWith('/library/studysets/') || path.startsWith('/studysets/');
/// Studying a studyset, over the tabs; [query] says how, when not as the
/// person's settings have it (a study button's focus).
static String study(

View file

@ -1,30 +1,41 @@
import 'dart:math' as math;
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:material_ui/material_ui.dart';
/// Where a studyset sits, on one line: "Library › Japanese › Kana ›
/// Hiragana", the steps as plain links in a muted colour, the studyset
/// itself last, bold in the primary colour.
///
/// Every step before it leads there by replacing the location rather than
/// pushing it, so the back button then leads further up, not back down.
/// When the line doesn't fit, the first steps fold into a "…" that lists
/// them, nearest first; the studyset itself always shows.
/// Every step before it goes back up there ([goUpTo]): the screens behind
/// a studyset are its path, so going up closes the screens above, as the
/// back button does one at a time. When the line doesn't fit, the first
/// steps fold into a "…" that lists them, nearest first; the studyset itself
/// always shows. Every step is a target [minTarget] high, as Material asks
/// of anything tapped, though its text is smaller.
class const StudysetBreadcrumb({
/// The studysets it sits in, from the top level down.
required final List<Studyset> ancestors,
/// The studyset.
required final Studyset current,
/// All the person's studysets, to open a step anew where its screen isn't
/// beneath.
required final List<Studyset> all,
/// What ends the line, after the path.
final Widget? trailing,
super.key,
}) extends StatelessWidget {
/// The room around a separator, on each side.
static const _separatorPadding = 6.0;
/// How high every step is, to tap.
static const minTarget = 48.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -36,84 +47,94 @@ class const StudysetBreadcrumb({
fontWeight: FontWeight.w600,
);
final steps = <_Step>[
_Step(l10n.libraryTitle, () => context.go(RoutePaths.library)),
_Step(l10n.libraryTitle, () => goUpTo(context, null, all)),
for (final ancestor in ancestors)
_Step(
ancestor.content.title,
() => context.go(RoutePaths.studyset(ancestor.id)),
),
_Step(ancestor.content.title, () => goUpTo(context, ancestor.id, all)),
];
Widget separator() => Padding(
padding: const EdgeInsets.symmetric(horizontal: _separatorPadding),
child: Text('›', style: muted),
);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: LayoutBuilder(
builder: (context, constraints) {
final scale = MediaQuery.textScalerOf(context);
double widthOf(String label, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: label, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: scale,
)..layout();
final width = painter.width;
painter.dispose();
return width;
}
final trailing = this.trailing;
final path = LayoutBuilder(
builder: (context, constraints) {
final scale = MediaQuery.textScalerOf(context);
double widthOf(String label, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: label, style: style),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: scale,
)..layout();
final width = painter.width;
painter.dispose();
return width;
}
final separatorWidth = widthOf('›', muted) + 2 * _separatorPadding;
final widths = [for (final step in steps) widthOf(step.label, muted)];
final currentWidth = widthOf(current.content.title, active);
final foldWidth = widthOf('…', muted);
// Fold the first steps away until the rest fits; at the least, the
// fold and the studyset itself remain.
var folded = 0;
double widthFrom(int first) =>
(first > 0 ? foldWidth + separatorWidth : 0) +
widths.skip(first).fold(0.0, (sum, w) => sum + w) +
(steps.length - first) * separatorWidth +
currentWidth;
while (folded < steps.length &&
widthFrom(folded) > constraints.maxWidth) {
folded++;
}
return Row(
children: [
if (folded > 0) ...[
_Fold(
steps: steps.take(folded).toList().reversed.toList(),
style: muted,
hover: muted.copyWith(color: active.color),
),
separator(),
],
for (final step in steps.skip(
math.min(folded, steps.length),
)) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: step.onTap,
child: Text(step.label, maxLines: 1, style: muted),
final separatorWidth = widthOf('›', muted) + 2 * _separatorPadding;
final widths = [for (final step in steps) widthOf(step.label, muted)];
final currentWidth = widthOf(current.content.title, active);
const foldWidth = _Fold.width;
// Fold the first steps away until the rest fits; at the least, the
// fold and the studyset itself remain.
var folded = 0;
double widthFrom(int first) =>
(first > 0 ? foldWidth + separatorWidth : 0) +
widths.skip(first).fold(0.0, (sum, w) => sum + w) +
(steps.length - first) * separatorWidth +
currentWidth;
while (folded < steps.length &&
widthFrom(folded) > constraints.maxWidth) {
folded++;
}
return Row(
children: [
if (folded > 0) ...[
_Fold(
steps: steps.take(folded).toList().reversed.toList(),
style: muted,
hover: muted.copyWith(color: active.color),
),
separator(),
],
for (final step in steps.skip(
math.min(folded, steps.length),
)) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: step.onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: minTarget),
child: Center(
widthFactor: 1,
child: Text(step.label, maxLines: 1, style: muted),
),
),
),
separator(),
],
Flexible(
child: Text(
current.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: active,
),
),
separator(),
],
);
},
Flexible(
child: Text(
current.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: active,
),
),
],
);
},
);
return Padding(
padding: EdgeInsets.fromLTRB(16, 0, trailing == null ? 16 : 8, 0),
child: Row(
children: [
Expanded(child: path),
if (trailing != null) ...[const SizedBox(width: 8), trailing],
],
),
);
}
@ -123,12 +144,15 @@ class const StudysetBreadcrumb({
class const _Step(final String label, final VoidCallback onTap);
/// The steps that didn't fit, behind a "…" that turns primary under the
/// pointer, nearest first in its menu.
/// pointer, nearest first in its menu; a square target as high as a step.
class const _Fold({
required final List<_Step> steps,
required final TextStyle style,
required final TextStyle hover,
}) extends StatefulWidget {
/// How wide it is.
static const width = StudysetBreadcrumb.minTarget;
@override
State<_Fold> createState() => _FoldState();
}
@ -149,7 +173,12 @@ class _FoldState() extends State<_Fold> {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => menu.isOpen ? menu.close() : menu.open(),
child: Text('…', style: _hovered ? widget.hover : widget.style),
child: SizedBox.square(
dimension: _Fold.width,
child: Center(
child: Text('…', style: _hovered ? widget.hover : widget.style),
),
),
),
),
);

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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:material_ui/material_ui.dart';
// The screens behind a studyset are its path (decided 2026-10-11): below
// "Japanese › Grammar › Tae Kim's Grammar Guide" lie Grammar's screen, then
// Japanese's, then the library. So the back button, Android's back (whose
// preview shows the screen it leads to), iOS's swipe from the edge and the
// path's steps all lead one level up, as Android's principles of navigation
// ask: a screen opened from elsewhere gets the screens it sits in beneath
// it, as if reached step by step.
/// Opens the studyset [id] in the library tab with the screens of the
/// studysets it sits in beneath it, the top level first; [all] are the
/// person's studysets.
void openStudyset(BuildContext context, String id, List<Studyset> all) {
final router = GoRouter.of(context);
final path = [for (final studyset in ancestorsOf(id, all)) studyset.id, id];
router.go(RoutePaths.studyset(path.first));
for (final next in path.skip(1)) {
unawaited(router.push<void>(RoutePaths.studyset(next)));
}
}
/// Opens the studyset [id] from the screen of the studyset [from] it sits
/// in, at any depth: the screens of the studysets between them come in
/// beneath it. From anywhere else, as [openStudyset] does.
void openStudysetWithin(
BuildContext context, {
required String from,
required String id,
required List<Studyset> all,
}) {
final ancestors = [for (final studyset in ancestorsOf(id, all)) studyset.id];
final start = ancestors.indexOf(from);
if (start < 0) {
openStudyset(context, id, all);
return;
}
final router = GoRouter.of(context);
for (final next in [...ancestors.skip(start + 1), id]) {
unawaited(router.push<void>(RoutePaths.studyset(next)));
}
}
/// Goes up to the screen of the studyset [id], closing those above it; to
/// the library without one. Where its screen isn't beneath (a stack built
/// before this rule), it opens anew as [openStudyset] does.
void goUpTo(BuildContext context, String? id, List<Studyset> all) {
if (id == null) {
context.go(RoutePaths.library);
return;
}
final location = RoutePaths.studyset(id);
var found = false;
Navigator.of(context).popUntil((route) {
found = route.settings.name == location;
return found || route.isFirst;
});
if (!found) {
openStudyset(context, id, all);
}
}
/// A studyset's back button, one level up; held down, it lists every level
/// above, the nearest first and the library last, as iOS's back buttons do
/// (since iOS 14), to go up to any of them at once.
class const StudysetBackButton({
/// The studysets the shown one sits in, from the top level down.
required final List<Studyset> ancestors,
/// All the person's studysets, for [goUpTo].
required final List<Studyset> all,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final back = MaterialLocalizations.of(context).backButtonTooltip;
return MenuAnchor(
menuChildren: [
for (final ancestor in ancestors.reversed)
MenuItemButton(
onPressed: () => goUpTo(context, ancestor.id, all),
child: Text(ancestor.content.title),
),
MenuItemButton(
onPressed: () => goUpTo(context, null, all),
child: Text(AppLocalizations.of(context).libraryTitle),
),
],
builder: (context, menu, _) => GestureDetector(
onLongPress: menu.open,
// The name shows under the pointer, but a long press is the list's:
// a tooltip would take it on a touch screen.
child: Tooltip(
message: back,
triggerMode: TooltipTriggerMode.manual,
child: IconButton(
icon: const BackButtonIcon(),
onPressed: () => Navigator.maybePop(context),
),
),
),
);
}
}

View file

@ -1,7 +1,5 @@
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/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
@ -11,6 +9,7 @@ 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/widget/delete_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -22,7 +21,9 @@ import 'package:material_ui/material_ui.dart';
/// everything inside it, and the study it invites, in view also while the
/// topics are rearranged. Below, the topics in their order, each opening its
/// own screen on top of this one, with the study it invites and its options;
/// a switch rearranges them by dragging. New sub-studyset stays at the foot,
/// those with topics of their own unfold them beneath, at any depth, each
/// opening with the screens of the levels between beneath it
/// ([openStudysetWithin]). A switch rearranges the topics by dragging. New sub-studyset stays at the foot,
/// across from [footEnd], with or without topics, so the foot looks the same
/// in every studyset (decided 2026-10-08). Without topics, a word on what
/// they are for in their place.
@ -38,6 +39,14 @@ class const StudysetRail({
/// What the foot ends on, across from New sub-studyset.
final Widget? footEnd,
/// Opens a topic, at any depth, in place of the rail: in the topics'
/// sheet, which closes first. The rail opens it itself without.
final void Function(Studyset topic)? onOpen,
/// Studies the whole studyset or a topic in place of the rail, as
/// [onOpen] opens one.
final void Function(Studyset studyset)? onStudy,
super.key,
}) extends StatefulWidget {
@override
@ -48,6 +57,9 @@ class _StudysetRailState() extends State<StudysetRail> {
var _reordering = false;
final _scroll = ScrollController();
/// The topics, at any depth, whose own topics show beneath them.
final _unfolded = <String>{};
@override
void dispose() {
_scroll.dispose();
@ -85,27 +97,55 @@ class _StudysetRailState() extends State<StudysetRail> {
}
}
/// Closes the drawer the rail may be in.
void _closeDrawer() {
if (Scaffold.maybeOf(context) case final scaffold?
when scaffold.isDrawerOpen) {
scaffold.closeDrawer();
}
}
/// Opens [studyset]'s screen, closing the drawer it may be in.
/// Opens [studyset]'s screen, with those of the levels between beneath.
void _open(Studyset studyset) {
_closeDrawer();
context.push(RoutePaths.studyset(studyset.id));
if (widget.onOpen case final open?) {
open(studyset);
return;
}
openStudysetWithin(
context,
from: widget.parent.id,
id: studyset.id,
all: widget.all,
);
}
Future<void> _study(Studyset studyset) async {
if (widget.onStudy case final study?) {
study(studyset);
return;
}
final bloc = context.read<StudysetListBloc>();
_closeDrawer();
await startStudying(context, studyset.id);
bloc.add(const StudysetListEventProgressRefreshRequested());
}
void _toggle(String id) => setState(
() => _unfolded.contains(id) ? _unfolded.remove(id) : _unfolded.add(id),
);
/// The topics inside [id] that show while it is unfolded, [depth] levels
/// below the rail's own, each with its own unfolded beneath it; [seen] are
/// the levels above, so a cycle (ADR 0004) unfolds no further.
List<Widget> _inner(String id, int depth, Set<String> seen) => [
for (final topic in childrenOf(id, widget.all))
if (!seen.contains(topic.id)) ...[
_InnerTopic(
key: ValueKey(topic.id),
topic: topic,
depth: depth,
unfolded: _unfolded.contains(topic.id),
onToggle: childrenOf(topic.id, widget.all).isEmpty
? null
: () => _toggle(topic.id),
onOpen: () => _open(topic),
),
if (_unfolded.contains(topic.id))
..._inner(topic.id, depth + 1, {...seen, topic.id}),
],
];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@ -132,12 +172,7 @@ class _StudysetRailState() extends State<StudysetRail> {
parent: widget.parent,
progress: progress[widget.parent.id],
onStudy: () => _study(widget.parent),
onAdd: addMaterial == null
? null
: () {
_closeDrawer();
addMaterial();
},
onAdd: addMaterial,
),
ListTile(
dense: true,
@ -230,11 +265,15 @@ class _StudysetRailState() extends State<StudysetRail> {
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 4),
children: [
for (final topic in topics)
for (final topic in topics) ...[
_Topic(
key: ValueKey(topic.id),
topic: topic,
progress: progress[topic.id],
unfolded: _unfolded.contains(topic.id),
onToggle: childrenOf(topic.id, widget.all).isEmpty
? null
: () => _toggle(topic.id),
onOpen: () => _open(topic),
onStudy: () => _study(topic),
onEdit: () => _edit(topic),
@ -245,6 +284,9 @@ class _StudysetRailState() extends State<StudysetRail> {
topic,
),
),
if (_unfolded.contains(topic.id))
..._inner(topic.id, 1, {widget.parent.id, topic.id}),
],
],
),
),
@ -412,11 +454,14 @@ class const _AllTopics({
}
}
/// A topic: the study it invites, its title, what it holds, and its
/// options behind "…" or a long press, which opens them where the finger is.
/// A topic: the study it invites, its title, what it holds, a chevron
/// unfolding its own topics where it has any ([onToggle]), and its options
/// behind "…" or a long press, which opens them where the finger is.
class const _Topic({
required final Studyset topic,
required final StudyProgress? progress,
required final bool unfolded,
required final VoidCallback? onToggle,
required final VoidCallback onOpen,
required final VoidCallback onStudy,
required final VoidCallback onEdit,
@ -509,6 +554,11 @@ class _TopicState() extends State<_Topic> {
],
),
),
if (widget.onToggle case final toggle?)
_UnfoldButton(
unfolded: widget.unfolded,
onPressed: toggle,
),
// The menu opens below it.
IconButton(
icon: const Icon(LucideIcons.ellipsis),
@ -538,6 +588,80 @@ class _TopicState() extends State<_Topic> {
}
}
/// A topic inside a topic, [depth] levels below the rail's own, indented
/// as deep: its title, opening it, and a chevron unfolding its own topics
/// where it has any ([onToggle]). Lighter than the rail's own topics: what
/// it holds and its options are on its screen.
class const _InnerTopic({
required final Studyset topic,
required final int depth,
required final bool unfolded,
required final VoidCallback? onToggle,
required final VoidCallback onOpen,
super.key,
}) extends StatelessWidget {
/// How far each level is indented.
static const indent = 20.0;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final toggle = onToggle;
return Padding(
padding: EdgeInsets.fromLTRB(12 + depth * indent, 0, 12, 0),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Row(
children: [
Icon(
LucideIcons.cornerDownRight,
size: 16,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: TitleWithKicker(
title: topic.content.title,
kicker: topic.content.secondaryTitle,
titleMaxLines: 1,
tooltip: topic.content.title,
),
),
if (toggle != null)
_UnfoldButton(unfolded: unfolded, onPressed: toggle),
],
),
),
),
);
}
}
/// A chevron that unfolds a topic's own topics beneath it, and folds them
/// again.
class const _UnfoldButton({
required final bool unfolded,
required final VoidCallback onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return IconButton(
icon: Icon(
unfolded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
tooltip: unfolded
? l10n.studysetHideInnerTopics
: l10n.studysetShowInnerTopics,
onPressed: onPressed,
);
}
}
/// The study a studyset invites, as a round icon on [background] in
/// [foreground]: a review when reviews are due, studying when only new
/// cards are, adding content otherwise (where [onAdd] is given), and a pause

View file

@ -61,6 +61,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/license_footer
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
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_rail.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -71,10 +72,11 @@ import 'package:material_ui/material_ui.dart';
///
/// From expanded windows on, two columns: the description over the topics
/// on the left, the flashcards on the right, and Study and Add material in
/// the app bar. Below that, the topics are in a drawer, the description
/// sits above the flashcards, and Study and Add material float at the
/// bottom, with the button that opens the topics, sliding away while
/// reading onwards. The licence, if any, is the last line. The studyset's
/// the app bar. Below that, the topics open in a sheet from the end of the
/// path's line, the description sits above the flashcards, and Study and
/// Add material float at the bottom where the navigation bar steps aside,
/// sliding away while reading onwards (decided 2026-10-11: no drawer, whose
/// swipe from the edge is iOS's way back). The licence, if any, is the last line. The studyset's
/// colour, its own or the nearest above it, washes down from the top
/// ([TopGradient]); cobalt where none has one.
class const StudysetScreen({
@ -263,8 +265,8 @@ class _StudysetViewState() extends State<_StudysetView> {
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 them in
// the drawer.
// sub-studyset: in the side bar from expanded windows on, below that in
// a sheet ([_showTopics]).
final rail = StudysetRail(
parent: studyset,
all: all,
@ -339,14 +341,15 @@ class _StudysetViewState() extends State<_StudysetView> {
final canGoBack = context.canPop();
return Scaffold(
backgroundColor: Colors.transparent,
// The topics' home below expanded windows.
drawer: wide ? null : Drawer(child: SafeArea(child: rail)),
appBar: AppBar(
backgroundColor: Colors.transparent,
systemOverlayStyle: TopGradient.overlayStyle(context),
// A drawer would take the back button's place; the bar at the
// bottom opens the topics instead.
leading: !wide && canGoBack ? const BackButton() : null,
leading: canGoBack
? StudysetBackButton(
ancestors: ancestorsOf(studyset.id, all),
all: all,
)
: null,
title: TitleWithKicker(
title: studyset.content.title,
kicker: studyset.content.secondaryTitle,
@ -415,6 +418,19 @@ class _StudysetViewState() extends State<_StudysetView> {
StudysetBreadcrumb(
ancestors: ancestorsOf(studyset.id, all),
current: studyset,
all: all,
trailing: wide
? null
: _TopicsButton(
count: context.select<StudysetListBloc, int>(
(bloc) => switch (bloc.state) {
StudysetListStateLoaded(:final studysets) =>
studysets.length,
_ => 0,
},
),
onPressed: () => _showTopics(context),
),
),
CopyUpdateBanner(studyset: studyset),
if (!wide && description != null)
@ -470,6 +486,63 @@ class _StudysetViewState() extends State<_StudysetView> {
),
);
}
/// How much of the window's height the topics' sheet takes.
static const _sheetHeight = 0.7;
/// Shows the topics in a sheet, below expanded windows, and does what was
/// chosen there once it has closed: from this screen, which stays, rather
/// than from the sheet, which goes.
Future<void> _showTopics(BuildContext context) async {
final studyset = widget.studyset;
final all = widget.all;
final bloc = context.read<StudysetListBloc>();
final choice = await showAdaptiveSheet<_TopicsChoice>(
context,
builder: (sheetContext) {
void choose(_TopicsChoice choice) =>
Navigator.of(sheetContext).pop(choice);
return BlocProvider.value(
value: bloc,
child: SizedBox(
height: MediaQuery.sizeOf(sheetContext).height * _sheetHeight,
// Snack bars of its own, so a topic deleted offers its Undo on
// the sheet rather than behind it.
child: ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
top: false,
child: StudysetRail(
parent: studyset,
all: all,
onAddMaterial: () => choose(const _AddMaterialChoice()),
onOpen: (topic) => choose(_OpenTopic(topic)),
onStudy: (chosen) => choose(_StudyTopic(chosen)),
footEnd: _BibliographyButton(studyset: studyset),
),
),
),
),
),
);
},
);
if (!context.mounted) {
return;
}
switch (choice) {
case _OpenTopic(:final topic):
openStudysetWithin(context, from: studyset.id, id: topic.id, all: all);
case _StudyTopic(studyset: final chosen):
await startStudying(context, chosen.id);
bloc.add(const StudysetListEventProgressRefreshRequested());
case _AddMaterialChoice():
_addMaterial.open();
case null:
break;
}
}
}
/// [FloatingActionButtonLocation.centerFloat], [lift] higher.
@ -1473,104 +1546,109 @@ Future<void> _importPath(BuildContext context) async {
bloc.add(PathListEventImportRequested(source));
}
/// Below expanded windows, at the bottom: the button that opens the topics
/// on the left, and Add material and Study centred.
/// Below expanded windows, at the bottom: Add material and Study, centred.
/// It is as wide as the window less its margins, so that asking, above it,
/// stands at the window's end.
class const _StudyBar({
required final Studyset studyset,
required final MenuController addMaterial,
}) extends StatelessWidget {
static const _margin = 16.0;
static const _button = 48.0;
static const _gap = 24.0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final width = math.max<double>(0, constraints.maxWidth - 2 * _margin);
return SizedBox(
width: width,
child: Stack(
alignment: Alignment.center,
builder: (context, constraints) => SizedBox(
width: math.max<double>(0, constraints.maxWidth - 2 * _margin),
child: Center(
heightFactor: 1,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Kept off the topics button's lane on both sides, so the
// pair sits in the bar's middle.
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.max(0, width - 2 * (_button + _gap)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_AddMaterial(
studysetId: studyset.id,
controller: addMaterial,
builder: (context, menu) => Tooltip(
message: l10n.studysetAddMaterial,
child: Material(
elevation: 6,
color: scheme.secondaryContainer,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
menu.isOpen ? menu.close() : menu.open(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
LucideIcons.plus,
color: scheme.onSecondaryContainer,
),
),
),
),
),
),
const SizedBox(width: 12),
Flexible(
child: _StudyButton(studyset: studyset, inAppBar: false),
),
],
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Center(
child: Tooltip(
message: l10n.studysetOpenTopics,
child: Material(
elevation: 2,
color: scheme.surface,
shape: CircleBorder(
side: BorderSide(color: scheme.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Scaffold.of(context).openDrawer(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
LucideIcons.panelLeftOpen,
color: scheme.onSurfaceVariant,
),
_AddMaterial(
studysetId: studyset.id,
controller: addMaterial,
builder: (context, menu) => Tooltip(
message: l10n.studysetAddMaterial,
child: Material(
elevation: 6,
color: scheme.secondaryContainer,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => menu.isOpen ? menu.close() : menu.open(),
child: Padding(
padding: const EdgeInsets.all(12),
child: Icon(
LucideIcons.plus,
color: scheme.onSecondaryContainer,
),
),
),
),
),
),
const SizedBox(width: 12),
Flexible(
child: _StudyButton(studyset: studyset, inAppBar: false),
),
],
),
);
},
),
),
);
}
}
/// Below expanded windows, at the end of the path's line: how many topics
/// the studyset has, opening them in a sheet (where Vaia puts "All
/// topics"); "Topics" without any, to make the first there.
class const _TopicsButton({
required final int count,
required final VoidCallback onPressed,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Tooltip(
message: l10n.studysetOpenTopics,
child: FilledButton.tonal(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 40),
padding: const EdgeInsets.fromLTRB(12, 0, 8, 0),
),
onPressed: onPressed,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(LucideIcons.layers, size: 18),
const SizedBox(width: 8),
Text(
count == 0 ? l10n.studysetTopics : l10n.studysetTopicCount(count),
),
const SizedBox(width: 4),
const Icon(LucideIcons.chevronDown, size: 18),
],
),
),
);
}
}
/// What was chosen in the topics' sheet, done once it has closed.
sealed class const _TopicsChoice();
/// Opening a topic, at any depth.
final class const _OpenTopic(final Studyset topic) extends _TopicsChoice;
/// Studying the whole studyset or a topic.
final class const _StudyTopic(final Studyset studyset) extends _TopicsChoice;
/// Adding material to the studyset.
final class const _AddMaterialChoice() extends _TopicsChoice;
enum _Option() {
edit,
move,
@ -1659,18 +1737,9 @@ class const _Options({
);
}
/// Back to where the person came from; to the studyset it sat in, or to
/// the library, when they came straight here.
void _leave(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
context.go(switch (studyset.parentId) {
final parentId? => RoutePaths.studyset(parentId),
null => RoutePaths.library,
});
}
}
/// Up to the studyset it sat in, whose screen is beneath, or to the
/// library.
void _leave(BuildContext context) => goUpTo(context, studyset.parentId, all);
@override
Widget build(BuildContext context) {

View file

@ -147,6 +147,43 @@ void main() {
expect(around(), scheme.surfaceContainerLow);
});
testWidgets('on a phone, the navigation bar steps aside inside a '
'studyset and its notes, and comes back in the library; inside, the '
"screens keep clear of the system's bar", (tester) async {
tester.view.padding = const FakeViewPadding(bottom: 24);
addTearDown(tester.view.resetPadding);
await showApp(tester, width: 400);
await openJapanese(tester);
expect(find.byType(NavigationBar), findsNothing);
expect(
tester.getBottomLeft(find.byType(StudysetScreen)).dy,
900 - 24,
);
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Add material'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create a note'));
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsNothing);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('in wider windows, the rail stays inside a studyset', (
tester,
) async {
await showApp(tester);
await openJapanese(tester);
expect(find.byType(NavigationRail), findsOneWidget);
});
testWidgets("the navigation bar is Material 3 Expressive's shorter one, "
'the label of the tab in view in the secondary colour', (tester) async {
await showApp(tester, width: 400);

View file

@ -401,7 +401,7 @@ void main() {
expect(center.dx, greaterThan(300));
expect(
center.dy,
lessThan(tester.getCenter(find.byTooltip('Show topics')).dy),
lessThan(tester.getCenter(find.byTooltip('Add material')).dy),
);
await unfold(tester);

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 '