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
16 changed files with 921 additions and 210 deletions
|
|
@ -95,7 +95,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
|
||||
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
|
||||
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
|
||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`) with the background of the screen in view around it (`ScreenTint`), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and a studyset's topic opens on top of it with `context.push` (the same route, keyed by id, so several can stack); the breadcrumb's steps go up with `context.go`, so back then leads further up. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
||||
- **Navigation is `go_router`**: the routes in `lib/src/app/app_router.dart`, their paths in `core/router/route_paths.dart`. Four tabs (Home, Library, Explore, Profile) are the branches of a `StatefulShellRoute`, each keeping its own stack of screens alive while another tab shows (`tabsContainer`, go_router's indexed stack plus `TabVisibility`, which a screen depends on to stop something once its tab is hidden); `AppShell` shows them as a navigation rail from 600 dp on and as a navigation bar below (Material 3 Expressive's shorter one, 64 dp, set in the theme: Flutter's Material library has none yet), floating above the bottom edge (`FloatingNavigationBar`) with the background of the screen in view around it (`ScreenTint`); it steps aside inside a studyset (its screen, notes, paths and dictionary: `RoutePaths.isInsideStudyset`), whose own bar at the bottom takes its place, leaving a strip as high as the system's bar (decided 2026-10-11, as Material 3 puts toolbars below the top level and Vaia does), and choosing the open tab again returns to its first screen. A tab's own screens are routes nested in its branch, reached with `context.go` (a studyset's screen in the library), and **the screens behind a studyset are its path** (decided 2026-10-11; `feature/studyset/widget/studyset_navigation.dart`): a topic, at any depth, opens with the screens of the levels between pushed beneath it (`openStudysetWithin`; the same route, keyed by id and named by its location, so several stack), the path's steps and the levels a long press on back lists go up by popping to that page (`goUpTo`), and a studyset opened from elsewhere gets its levels beneath it (`openStudyset`). So back, Android's predictive back, iOS's swipe from the edge and the path all lead one level up. Below expanded windows the topics open in a sheet from the end of the path's line, never a drawer, whose swipe from the edge is iOS's way back. A studyset's notes and learning paths (working through one, writing it, writing a step) are routes of the Library branch too, so the tabs stay beside them. What needs the whole window (studying, a card, a PDF, settings) is a top-level route over the tabs, reached with `context.push`. Explore lists what is published (see *Explore and copies*).
|
||||
- **Routes carry ids, never objects.** A screen finds its item in the repository's live list with `FoundById` (`core/widget/`): a spinner until the list arrives, then the item, or a screen saying it no longer exists (deleted on another device, or an old link). A screen that deletes its item closes, and keeps showing the item while it goes. Why: every location can be built from its path alone, and no screen goes on showing something that has gone.
|
||||
- **The look is one theme** (`core/theme/`): `appTheme(brightness)` with hand-written light and dark colour schemes around the brand's cobalt and copper; `Theme.of(context).extras` (`ThemeExtras`) for the colours Material has no role for (states, the four FSRS ratings, a starred star); `PaletteColor` for the 20 colours a person can give content; `TopGradient` for a colour washing down from the top of every tab's first screen, Settings and Connect Claude (cobalt) and of every screen of a studyset (its colour, own or inherited, which `_inStudyset` in the router hands down as `ScreenColor`): the studyset, studying it, its cards, writing them, its notes, paths and dictionary; behind a clear app bar, in dark mode a deep shade (42 % of the colour over black), in light mode a tint, held to WCAG AA for the theme's text. From medium windows on, a `TintFrame` takes the colour of the screen in view (`ScreenTint`, claimed by the `TopGradient` whose tab shows and whose route is on top), fading out by 60 % of the height: the rail sits on it, transparent, and the screen is a rounded panel inset beside it, its own wash half as strong; screens over the tabs (studying, the card browser and composer, Settings) sit in the same frame without the rail (`FramedScreen`). As Vaia does, decided 2026-10-10. The PDF reader and the reading timeline stay plain. `theme_test.dart` holds text to WCAG AA contrast (4.5:1) on each palette colour and on the dark theme's primary colour and surface. **Fonts are bundled, never fetched** (`core/theme/app_fonts.dart`, `assets/fonts/ATTRIBUTION.md`): Noto Sans is the app's font on every device (Latin, Greek with polytonic, Cyrillic); Noto Sans JP, SC, TC and KR fill in Japanese, Chinese and Korean, Japanese first unless a studyset's language puts its own first (`StudysetLanguageScope`, which every studyset route wraps its screen in through `_inStudyset`; Han characters take the first listed font's shapes); each is one variable font holding every weight, and their OFL licences join the licence list. Scripts none covers (Arabic, Hebrew, Thai, …) fall back to the device's own fonts. A studyset's languages (`frontLanguage`, `backLanguage`, BCP 47) are given together or inherited together from the nearest studyset above (`languagesOf`). Never add `google_fonts` or anything else that downloads fonts. Layouts follow Material 3's window-size classes (`context.windowSize`, `core/layout/`), and running text stays within `readableContentMaxWidth`. Icons are Lucide (`lucide_icons_flutter`), except a starred star and the marks of a choice not yet checked (filled box and circle), which need the fill Lucide lacks; the Material icons left in older screens go when those are restyled. Shared widgets are in `core/widget/` (`SectionCard`, `AppLoadingIndicator`, `confirmDestructiveAction`, `FoundById`, `TitleWithKicker`, `ContentCountsLine`, `showUndoSnackBar`, `ScrollHidingFab`, `PaletteColorPicker`, `CountStepper`, `RequiredFieldLabel` and `FieldInfoButton`, `ReorderToggle`, `ReorderGrip`, `liftWhileDragged`, `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below).
|
||||
- **Lists that can grow long are built as they scroll into view**: a studyset's tabs are `CustomScrollView`s holding each list's slivers (`SliverList`, `SliverReorderableList`), and what a list says in place of its rows sits in a `SliverCentered`. Never a `shrinkWrap` list inside another scrolling view: that builds every row at once, and 1,000 cards then froze the window for seconds, long enough for the desktop to offer to kill the app. Rows are put in another order in a Reorder mode (`ReorderToggle` beside the list; a `ReorderGrip` in place of each row's options; positions from `core/sync/fractional_position.dart`), never with Flutter's default drag handles, which on the desktop lie over a row's trailing end, its … button. Flashcards have no Reorder (decided 2026-10-08): they keep the order they were created or imported in, which new cards and cramming follow, and sub-studysets put lessons in order; no one drags a card among a thousand. **The card browser** (`feature/card/widget/card_browser_screen.dart`, decided 2026-10-09: the older design, gaps filled) opens from Browse beside the flashcards' tab, over the tabs (`/studysets/:id/flashcards`, `?subtree=1` with everything inside): grid or detail (one card beside the list; on a phone above a strip), as last left per studyset on the device (`DeviceSettings.detailedBrowsers`); search over front, back, reading, meanings, hints, explanation and tag names (katakana as hiragana, romaji finding kana; `card_browser.dart`); sort (own order, newest, oldest, A–Z by reading, due first); a filter sheet (kind, one tag, state: new, learning, due, buried, reported); and choosing cards (a toggle; shift-click or long-press for a range) to move (`showCardMoveSheet`), tag, untag, start afresh or delete with Undo. It reads every card of the studysets, as lists do (schedules from the review repository's cache).
|
||||
|
|
|
|||
|
|
@ -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,20 +47,16 @@ 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(
|
||||
final trailing = this.trailing;
|
||||
final path = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scale = MediaQuery.textScalerOf(context);
|
||||
double widthOf(String label, TextStyle style) {
|
||||
|
|
@ -67,7 +74,7 @@ class const StudysetBreadcrumb({
|
|||
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);
|
||||
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;
|
||||
|
|
@ -98,9 +105,15 @@ class const StudysetBreadcrumb({
|
|||
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(
|
||||
|
|
@ -114,6 +127,14 @@ class const StudysetBreadcrumb({
|
|||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
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,8 +173,13 @@ class _FoldState() extends State<_Fold> {
|
|||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
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,34 +1546,24 @@ 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,
|
||||
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)),
|
||||
),
|
||||
builder: (context, constraints) => SizedBox(
|
||||
width: math.max<double>(0, constraints.maxWidth - 2 * _margin),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -1515,8 +1578,7 @@ class const _StudyBar({
|
|||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
menu.isOpen ? menu.close() : menu.open(),
|
||||
onTap: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
|
|
@ -1535,42 +1597,58 @@ class const _StudyBar({
|
|||
],
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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