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
15 changed files with 921 additions and 210 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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