feat(paths): confetti for a finished path, and an import's progress
All checks were successful
CI / check (push) Successful in 13m32s
CI / deploy (push) Successful in 19s

Finishing a path bursts confetti over the view saying so: pieces in the
palette's brightest colours fall once from the top, turning, and fade
out in under two seconds, so the screen settles. Drawn by hand rather
than with a package; nothing falls with animations switched off on the
device.

Importing paths into a studyset shows its Paths tab at once, and above
the paths, as they come in, how far the import has come: which path of
how many, by its title, and a bar filling with every path and step
stored. Before, the paths appeared one by one in whatever tab showed,
with nothing saying how many were still to come.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 21:23:11 +02:00
commit b8e4e28a08
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 549 additions and 100 deletions

View file

@ -99,7 +99,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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. - **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`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `SliverCentered` and `EmptyListMessage` (what an empty list shows: icon, "No … yet", and which menus add some), and the sheets below). - **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`, `LaunchScreen` (what shows while the app opens: the kingfisher, "Halkyon" over "Learn"), `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). - **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).
- **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them. A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none. - **Forms and pickers open as sheets**: `showAdaptiveSheet` with a `SheetScaffold` (title and close button on top, the body scrolling with a scrollbar, always shown on the desktop, and Material 3's lines under the title and above the footer while some of the body lies beyond them, the action pinned below) shows a bottom sheet below expanded windows and a dialog from there on. A sheet that asks before discarding changes (a `PopScope`) passes `swipeable: false`: a bottom sheet swiped away closes without asking its `PopScope` (Flutter issue 152120, open with v3.47). A sheet offering an Undo passes `hasSnackBars: true`, so its snack bars show on it rather than behind its barrier. A sheet running something that mustn't be cut off (an import) is `busy` meanwhile: its close button is greyed and back does nothing; it is shown with `swipeable: false`, since a swipe closes a bottom sheet regardless. A screen whose blocs import (a studyset's PDFs and paths) greys its back button and path likewise, as leaving would close them; an import of paths shows the Paths tab at once, how far it has come above the paths (`PathImportProgress`). A picker that changes something outside it reports every change as it happens (`onChanged`) rather than as its result, since a sheet swiped away has none.
- **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names. - **Every text field has a limit** (decided 2026-10-11): `TextLimits` (`core/widget/text_limits.dart`) by what it holds (a name 40, a secondary title 60, a title 120, an item 200, a search 200, a citation's part 500, a message or an address 2000, a passage 5000), or the instance's own where the server checks it (`PublicProfile.max…`, `maxSlugLength`), with `buildCounter: nearLimitCounter`. Text can still be longer where it came from elsewhere, so every place showing a name cuts it (`maxLines` and an ellipsis, in a `Flexible` within a row); `test/app/long_names_test.dart` shows names beyond every limit on the main screens of a phone and a desktop, and fails on any overflow: add a screen there when it shows names.
- **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files. - **Text comes from `AppLocalizations`** (Flutter's gen-l10n, `lib/l10n/`): no string a person reads is written into a widget. The app speaks British English, American English and German (informal "du"; the terms of `app_de.arb`, such as Lernset and Karteikarte, stay consistent). `app_en.arb` is the template and holds British English, also the fallback for any language the app doesn't speak; `app_en_US.arb` overrides only what American English spells differently, and `app_en_GB.arb` is empty, there so that Material and dates format the British way. A new message goes into `app_en.arb` and `app_de.arb` together (`test/l10n/arb_files_test.dart` fails otherwise, and checks the placeholders), and into `app_en_US.arb` if its spelling differs. `resolveAppLocale` (`core/l10n/`) picks the language, never by country alone, and both `MaterialApp`s take `appLocalizationsDelegates`, not gen-l10n's list: that brings flutter_localizations' Material texts, which serve only the SDK's Material, so material_ui's widgets would stay English. Errors found in data go up as values the widgets put into words (`PathImportProblem`, `PathImportTexts`), never as English sentences. The editor's own placeholders are replaced by ours (`buildersWithUnknown`), since the editor's texts follow a process-wide locale of their own. The generated `lib/l10n/app_localizations*.dart` are committed and never edited, analysed or formatted by hand. `flutter pub get` writes them anew, so CI fails when they don't match the `.arb` files.

View file

@ -97,6 +97,8 @@
"pathImportSkipped": "{count, plural, =1{1 Block wurde ausgelassen: Bilder und Animationen werden nicht importiert.} other{{count} Blöcke wurden ausgelassen: Bilder und Animationen werden nicht importiert.}}", "pathImportSkipped": "{count, plural, =1{1 Block wurde ausgelassen: Bilder und Animationen werden nicht importiert.} other{{count} Blöcke wurden ausgelassen: Bilder und Animationen werden nicht importiert.}}",
"pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}", "pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}",
"pathImportFileFailed": "{file} konnte nicht importiert werden. {reason}", "pathImportFileFailed": "{file} konnte nicht importiert werden. {reason}",
"pathImportReading": "{count, plural, =1{Datei wird gelesen …} other{{count} Dateien werden gelesen …}}",
"pathImportStoring": "{count, plural, =1{„{title}“ wird importiert …} other{Pfad {index} von {count} wird importiert: „{title}“ …}}",
"pathsImported": "{steps, plural, =0{{count} Pfade ohne Schritte importiert.} =1{{count} Pfade mit 1 Schritt importiert.} other{{count} Pfade mit {steps} Schritten importiert.}}", "pathsImported": "{steps, plural, =0{{count} Pfade ohne Schritte importiert.} =1{{count} Pfade mit 1 Schritt importiert.} other{{count} Pfade mit {steps} Schritten importiert.}}",
"pathImportUnreadable": "Der Inhalt ist kein Text in UTF-8.", "pathImportUnreadable": "Der Inhalt ist kein Text in UTF-8.",
"pathsEmpty": "Noch keine Pfade", "pathsEmpty": "Noch keine Pfade",

View file

@ -112,6 +112,10 @@
"pathImportFailed": "Couldn't import this file. {reason}", "pathImportFailed": "Couldn't import this file. {reason}",
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}}, "@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
"pathImportFileFailed": "Couldn't import {file}. {reason}", "pathImportFileFailed": "Couldn't import {file}. {reason}",
"pathImportReading": "{count, plural, =1{Reading the file…} other{Reading {count} files…}}",
"@pathImportReading": {"placeholders": {"count": {"type": "int"}}},
"pathImportStoring": "{count, plural, =1{Importing “{title}”…} other{Importing path {index} of {count}: “{title}”…}}",
"@pathImportStoring": {"placeholders": {"index": {"type": "int"}, "count": {"type": "int"}, "title": {"type": "String"}}},
"@pathImportFileFailed": {"placeholders": {"file": {"type": "String"}, "reason": {"type": "String"}}}, "@pathImportFileFailed": {"placeholders": {"file": {"type": "String"}, "reason": {"type": "String"}}},
"pathsImported": "{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}", "pathsImported": "{steps, plural, =0{Imported {count} paths without steps.} =1{Imported {count} paths with 1 step.} other{Imported {count} paths with {steps} steps.}}",
"@pathsImported": {"placeholders": {"count": {"type": "int"}, "steps": {"type": "int"}}}, "@pathsImported": {"placeholders": {"count": {"type": "int"}, "steps": {"type": "int"}}},

View file

@ -640,6 +640,18 @@ abstract class AppLocalizations {
/// **'Couldn\'t import {file}. {reason}'** /// **'Couldn\'t import {file}. {reason}'**
String pathImportFileFailed(String file, String reason); String pathImportFileFailed(String file, String reason);
/// No description provided for @pathImportReading.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Reading the file…} other{Reading {count} files…}}'**
String pathImportReading(int count);
/// No description provided for @pathImportStoring.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Importing “{title}”…} other{Importing path {index} of {count}: “{title}”…}}'**
String pathImportStoring(int index, int count, String title);
/// No description provided for @pathsImported. /// No description provided for @pathsImported.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -400,6 +400,28 @@ class AppLocalizationsDe extends AppLocalizations {
return '$file konnte nicht importiert werden. $reason'; return '$file konnte nicht importiert werden. $reason';
} }
@override
String pathImportReading(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Dateien werden gelesen …',
one: 'Datei wird gelesen …',
);
return '$_temp0';
}
@override
String pathImportStoring(int index, int count, String title) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Pfad $index von $count wird importiert: „$title“ …',
one: '„$title“ wird importiert …',
);
return '$_temp0';
}
@override @override
String pathsImported(int count, int steps) { String pathsImported(int count, int steps) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

View file

@ -392,6 +392,28 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Couldn\'t import $file. $reason'; return 'Couldn\'t import $file. $reason';
} }
@override
String pathImportReading(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Reading $count files…',
one: 'Reading the file…',
);
return '$_temp0';
}
@override
String pathImportStoring(int index, int count, String title) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Importing path $index of $count: “$title”…',
one: 'Importing “$title”…',
);
return '$_temp0';
}
@override @override
String pathsImported(int count, int steps) { String pathsImported(int count, int steps) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

View file

@ -30,7 +30,14 @@ class PathListBloc(
emit(state.copyWith(created: id)); emit(state.copyWith(created: id));
}); });
on<PathListEventImportRequested>((event, emit) async { on<PathListEventImportRequested>((event, emit) async {
emit(state.copyWith(importing: true)); // Reading the files: no path stored yet.
final PathImportProgress reading = (
title: '',
path: 0,
paths: event.files.length,
done: 0,
);
emit(state.copyWith(importProgress: reading));
// Every file is read before anything is stored, so one that can't be // Every file is read before anything is stored, so one that can't be
// read leaves the studyset as it was. // read leaves the studyset as it was.
final parsed = <ParsedPath>[]; final parsed = <ParsedPath>[];
@ -40,7 +47,7 @@ class PathListBloc(
} on PathImportException catch (error) { } on PathImportException catch (error) {
emit( emit(
state.copyWith( state.copyWith(
importing: false, importProgress: null,
importResult: ( importResult: (
title: '', title: '',
paths: 0, paths: 0,
@ -55,16 +62,37 @@ class PathListBloc(
} }
} }
final importer = PathImporter(_paths); final importer = PathImporter(_paths);
// A path and each of its steps are a share of the whole.
final shares = parsed.fold(0, (sum, path) => sum + 1 + path.steps.length);
var stored = 0;
var steps = 0; var steps = 0;
var skippedBlocks = 0; var skippedBlocks = 0;
for (final path in parsed) { for (final (index, path) in parsed.indexed) {
final imported = await importer.store(_studysetId, path); void report() => emit(
state.copyWith(
importProgress: (
title: path.content.title,
path: index + 1,
paths: parsed.length,
done: stored / shares,
),
),
);
report();
final imported = await importer.store(
_studysetId,
path,
onStored: () {
stored++;
report();
},
);
steps += imported.steps; steps += imported.steps;
skippedBlocks += imported.skippedBlocks; skippedBlocks += imported.skippedBlocks;
} }
emit( emit(
state.copyWith( state.copyWith(
importing: false, importProgress: null,
importResult: ( importResult: (
title: parsed.firstOrNull?.content.title ?? '', title: parsed.firstOrNull?.content.title ?? '',
paths: parsed.length, paths: parsed.length,
@ -207,6 +235,11 @@ typedef ImportResult = ({
/// A file in the paths' import format: its name and its text. /// A file in the paths' import format: its name and its text.
typedef PathFile = ({String name, String source}); typedef PathFile = ({String name, String source});
/// How far an import has come: the path being stored (its `title`, the
/// `path`th of `paths`, from 1; 0 and no title while the files are read) and
/// the share of everything stored, from 0 to 1 (`done`).
typedef PathImportProgress = ({String title, int path, int paths, double done});
/// A studyset's paths, as the list shows them. /// A studyset's paths, as the list shows them.
final class const PathListState({ final class const PathListState({
/// Whether the paths were read. /// Whether the paths were read.
@ -231,12 +264,15 @@ final class const PathListState({
/// null before any. /// null before any.
final ImportResult? importResult, final ImportResult? importResult,
/// Whether a path is being imported. /// How far the import running here has come; null while none runs.
final bool importing = false, final PathImportProgress? importProgress,
/// Whether the last read failed; the next change reads again. /// Whether the last read failed; the next change reads again.
final bool failed = false, final bool failed = false,
}) with Equatable { }) with Equatable {
/// Whether paths are being imported.
bool get importing => importProgress != null;
/// Whether some path is not completed yet. /// Whether some path is not completed yet.
bool get hasOpenPaths => paths.any((path) => !completed.contains(path.id)); bool get hasOpenPaths => paths.any((path) => !completed.contains(path.id));
@ -255,7 +291,7 @@ final class const PathListState({
bool? completionKnown, bool? completionKnown,
String? created, String? created,
ImportResult? importResult, ImportResult? importResult,
bool? importing, Object? importProgress = _keep,
bool? failed, bool? failed,
}) => PathListState( }) => PathListState(
loaded: loaded ?? this.loaded, loaded: loaded ?? this.loaded,
@ -265,7 +301,9 @@ final class const PathListState({
completionKnown: completionKnown ?? this.completionKnown, completionKnown: completionKnown ?? this.completionKnown,
created: created ?? this.created, created: created ?? this.created,
importResult: importResult ?? this.importResult, importResult: importResult ?? this.importResult,
importing: importing ?? this.importing, importProgress: identical(importProgress, _keep)
? this.importProgress
: importProgress as PathImportProgress?,
failed: failed ?? this.failed, failed: failed ?? this.failed,
); );
@ -278,11 +316,14 @@ final class const PathListState({
completionKnown, completionKnown,
created, created,
importResult, importResult,
importing, importProgress,
failed, failed,
]; ];
} }
/// What [PathListState.copyWith] takes to keep a field as it is.
const _keep = Object();
/// What happens in the list. /// What happens in the list.
sealed class const PathListEvent() with Equatable { sealed class const PathListEvent() with Equatable {
@override @override

View file

@ -119,11 +119,17 @@ class PathImporter(final LearningPathRepository _paths) {
store(studysetId, parsePathImport(source)); store(studysetId, parsePathImport(source));
/// Stores [parsed], a path read already, at the end of [studysetId], with /// Stores [parsed], a path read already, at the end of [studysetId], with
/// its steps. /// its steps; [onStored] after the path and after each step.
Future<ImportedPath> store(String studysetId, ParsedPath parsed) async { Future<ImportedPath> store(
String studysetId,
ParsedPath parsed, {
void Function()? onStored,
}) async {
final pathId = await _paths.createPath(studysetId, parsed.content); final pathId = await _paths.createPath(studysetId, parsed.content);
onStored?.call();
for (final (:kind, :content) in parsed.steps) { for (final (:kind, :content) in parsed.steps) {
await _paths.createStep(pathId, kind, content); await _paths.createStep(pathId, kind, content);
onStored?.call();
} }
return ( return (
pathId: pathId, pathId: pathId,

View file

@ -0,0 +1,150 @@
import 'dart:math';
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.dart';
import 'package:material_ui/material_ui.dart';
/// Confetti falling once over what is below it, for a path just finished:
/// pieces in the palette's brightest colours drift down from the top edge,
/// turning, and fade out before they leave. It plays once and ends, so the
/// screen settles; with animations switched off on the device, it shows
/// nothing. Drawn by hand: no package for one burst.
class const ConfettiBurst({super.key}) extends StatefulWidget {
/// How long the burst lasts.
static const duration = Duration(milliseconds: 1900);
@override
State<ConfettiBurst> createState() => _ConfettiBurstState();
}
class _ConfettiBurstState()
extends State<ConfettiBurst>
with SingleTickerProviderStateMixin {
static const _pieces = 60;
static const _colors = [
PaletteColor.carrot,
PaletteColor.strawberry,
PaletteColor.slateblue,
PaletteColor.fern,
PaletteColor.pacific,
PaletteColor.pink,
PaletteColor.olive,
PaletteColor.purple,
];
late final _controller = AnimationController(
vsync: this,
duration: ConfettiBurst.duration,
);
late final List<_Piece> _confetti;
@override
void initState() {
super.initState();
final random = Random();
_confetti = [
for (var i = 0; i < _pieces; i++)
_Piece.random(random, _colors[i % _colors.length].background),
];
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (MediaQuery.disableAnimationsOf(context)) {
return const SizedBox.shrink();
}
return IgnorePointer(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => _controller.isCompleted
? const SizedBox.expand()
: CustomPaint(
size: Size.infinite,
painter: _ConfettiPainter(_confetti, _controller.value),
),
),
);
}
}
/// A piece of confetti, in shares of the area it falls over, so it fits
/// any size.
class const _Piece({
/// Where it starts, across: 0 at the start, 1 at the end.
required final double start,
/// How far it drifts across while falling.
required final double drift,
/// How far it falls, down: more than 1, so it leaves the area.
required final double fall,
/// The share of the burst before it starts falling.
required final double delay,
/// Its length in logical pixels; it is 0.6 of that wide.
required final double size,
/// How many turns it makes while falling, the sign the direction.
required final double turns,
required final Color color,
}) {
factory random(Random random, Color color) => _Piece(
start: random.nextDouble(),
drift: (random.nextDouble() - 0.5) * 0.4,
fall: 0.9 + random.nextDouble() * 0.4,
delay: random.nextDouble() * 0.25,
size: 6 + random.nextDouble() * 6,
turns: (random.nextDouble() - 0.5) * 6,
color: color,
);
}
class const _ConfettiPainter(
final List<_Piece> _confetti,
/// How far the burst has come, from 0 to 1.
final double _progress,
) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint();
for (final piece in _confetti) {
// Each piece's own progress, from when it starts.
final t = ((_progress - piece.delay) / (1 - piece.delay)).clamp(0.0, 1.0);
if (t == 0) {
continue;
}
// Fading out over its last quarter, rather than vanishing.
final opacity = t < 0.75 ? 1.0 : (1 - t) / 0.25;
paint.color = piece.color.withValues(alpha: opacity);
canvas
..save()
..translate(
(piece.start + piece.drift * t) * size.width,
(t * piece.fall - 0.05) * size.height,
)
..rotate(piece.turns * t * 2 * pi)
..drawRect(
Rect.fromCenter(
center: Offset.zero,
width: piece.size,
height: piece.size * 0.6,
),
paint,
)
..restore();
}
}
@override
bool shouldRepaint(_ConfettiPainter oldDelegate) =>
oldDelegate._progress != _progress;
}

View file

@ -37,72 +37,129 @@ class const SliverPathList({
@override @override
Widget build(BuildContext context) => Widget build(BuildContext context) =>
BlocBuilder<PathListBloc, PathListState>( BlocBuilder<PathListBloc, PathListState>(
builder: (context, state) { builder: (context, state) => switch (state.importProgress) {
final l10n = AppLocalizations.of(context); // An import shows how far it has come above the paths it adds.
if (!state.loaded) { final progress? => SliverMainAxisGroup(
return SliverCentered( slivers: [
padding: padding, SliverPadding(
child: const AppLoadingIndicator(), padding: padding.copyWith(bottom: 0),
); sliver: SliverToBoxAdapter(
} child: _ImportProgress(progress: progress),
if (state.failed && state.paths.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.pathsReadFailed),
);
}
if (state.paths.isEmpty) {
return SliverCentered(
padding: padding,
child: EmptyListMessage(
icon: LucideIcons.route,
title: l10n.pathsEmpty,
hint: l10n.pathsEmptyHint,
),
);
}
final paths = state.paths;
if (reordering) {
return SliverPadding(
padding: padding,
sliver: SliverReorderableList(
itemCount: paths.length,
onReorderItem: (from, to) => context.read<PathListBloc>().add(
PathListEventMoveRequested(paths[from].id, to),
), ),
proxyDecorator: liftWhileDragged,
itemBuilder: (context, index) {
final path = paths[index];
return ListTile(
key: ValueKey(path.id),
leading: const Icon(LucideIcons.route),
title: TitleWithKicker(
title: path.content.title,
kicker: path.content.secondaryTitle,
titleMaxLines: 1,
),
trailing: ReorderGrip(index: index),
);
},
), ),
); if (state.paths.isNotEmpty)
} _paths(context, state, padding.copyWith(top: 0)),
return SliverPadding( ],
padding: padding, ),
sliver: SliverList.separated( null => _paths(context, state, padding),
itemCount: paths.length,
itemBuilder: (context, index) => _PathTile(
key: ValueKey(paths[index].id),
studysetId: studysetId,
path: paths[index],
completed: state.completed.contains(paths[index].id),
),
separatorBuilder: (context, _) => const Divider(height: 1),
),
);
}, },
); );
Widget _paths(BuildContext context, PathListState state, EdgeInsets padding) {
final l10n = AppLocalizations.of(context);
if (!state.loaded) {
return SliverCentered(
padding: padding,
child: const AppLoadingIndicator(),
);
}
if (state.failed && state.paths.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.pathsReadFailed),
);
}
if (state.paths.isEmpty) {
return SliverCentered(
padding: padding,
child: EmptyListMessage(
icon: LucideIcons.route,
title: l10n.pathsEmpty,
hint: l10n.pathsEmptyHint,
),
);
}
final paths = state.paths;
if (reordering) {
return SliverPadding(
padding: padding,
sliver: SliverReorderableList(
itemCount: paths.length,
onReorderItem: (from, to) => context.read<PathListBloc>().add(
PathListEventMoveRequested(paths[from].id, to),
),
proxyDecorator: liftWhileDragged,
itemBuilder: (context, index) {
final path = paths[index];
return ListTile(
key: ValueKey(path.id),
leading: const Icon(LucideIcons.route),
title: TitleWithKicker(
title: path.content.title,
kicker: path.content.secondaryTitle,
titleMaxLines: 1,
),
trailing: ReorderGrip(index: index),
);
},
),
);
}
return SliverPadding(
padding: padding,
sliver: SliverList.separated(
itemCount: paths.length,
itemBuilder: (context, index) => _PathTile(
key: ValueKey(paths[index].id),
studysetId: studysetId,
path: paths[index],
completed: state.completed.contains(paths[index].id),
),
separatorBuilder: (context, _) => const Divider(height: 1),
),
);
}
}
/// How far an import has come: the path being stored, which of how many,
/// and a bar filling as its steps are stored.
class const _ImportProgress({required final PathImportProgress progress})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Semantics(
liveRegion: true,
child: Text(
progress.path == 0
? l10n.pathImportReading(progress.paths)
: l10n.pathImportStoring(
progress.path,
progress.paths,
progress.title,
),
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress.done,
minHeight: 4,
borderRadius: BorderRadius.circular(2),
),
],
),
);
}
} }
/// One path: its route, its secondary title above its title, dimmed once /// One path: its route, its secondary title above its title, dimmed once

View file

@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/top_gradient.dart';
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart'; import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -413,13 +414,21 @@ class const _Footer({required final PathRunnerState state})
} }
} }
/// The path finished: how many steps, and where to go next. /// The path finished: how many steps, and where to go next, under a burst
/// of confetti.
class const _Finished({ class const _Finished({
required final PathRunnerState state, required final PathRunnerState state,
required final String studysetId, required final String studysetId,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Stack(
children: [
_finished(context),
const Positioned.fill(child: ConfettiBurst()),
],
);
Widget _finished(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return Center(

View file

@ -117,11 +117,9 @@ class const StudysetScreen({
), ),
// Its notes, for their tab and Reorder beside the tabs. // Its notes, for their tab and Reorder beside the tabs.
BlocProvider( BlocProvider(
create: (context) => NoteListBloc( create: (context) =>
person.notes, NoteListBloc(person.notes, person.noteDeletions, studyset.id)
person.noteDeletions, ..add(const NoteListEventStarted()),
studyset.id,
)..add(const NoteListEventStarted()),
), ),
// Its PDFs, for the Files tab and for Add material. // Its PDFs, for the Files tab and for Add material.
BlocProvider( BlocProvider(
@ -143,9 +141,8 @@ class const StudysetScreen({
BlocListener<PathListBloc, PathListState>( BlocListener<PathListBloc, PathListState>(
listenWhen: (before, now) => listenWhen: (before, now) =>
now.created != null && before.created != now.created, now.created != null && before.created != now.created,
listener: (context, state) => context.push( listener: (context, state) =>
RoutePaths.editPath(studyset.id, state.created!), context.push(RoutePaths.editPath(studyset.id, state.created!)),
),
), ),
// An import says what came in, or why nothing did. // An import says what came in, or why nothing did.
BlocListener<PathListBloc, PathListState>( BlocListener<PathListBloc, PathListState>(
@ -175,9 +172,8 @@ class const StudysetScreen({
l10n.pathImportSkipped(result.skippedBlocks), l10n.pathImportSkipped(result.skippedBlocks),
].join(' '), ].join(' '),
}; };
ScaffoldMessenger.of( ScaffoldMessenger.of(context)
context, .showSnackBar(SnackBar(content: Text(message)));
).showSnackBar(SnackBar(content: Text(message)));
}, },
), ),
// A PDF uploaded shows in Files; a file the studyset holds // A PDF uploaded shows in Files; a file the studyset holds
@ -644,9 +640,7 @@ class _ContentState()
var _reorderingFiles = false; var _reorderingFiles = false;
/// Each tab's scrolling, to go back to its top. /// Each tab's scrolling, to go back to its top.
final _scrolls = [ final _scrolls = [for (final _ in StudysetTab.values) ScrollController()];
for (final _ in StudysetTab.values) ScrollController(),
];
/// The tab shown, once the tabs have settled. /// The tab shown, once the tabs have settled.
var _active = StudysetTab.flashcards.index; var _active = StudysetTab.flashcards.index;
@ -1388,9 +1382,7 @@ class const _AddMaterial({
builder, builder,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => StudysetDictionary( Widget build(BuildContext context) => StudysetDictionary(builder: _menu);
builder: _menu,
);
Widget _menu(BuildContext context, Dictionary? dictionary) { Widget _menu(BuildContext context, Dictionary? dictionary) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -1449,9 +1441,8 @@ class const _AddMaterial({
Future<void> _uploadPdf(BuildContext context) async { Future<void> _uploadPdf(BuildContext context) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final bloc = context.read<PdfListBloc>(); final bloc = context.read<PdfListBloc>();
final file = await DependenciesScope.of( final file = await DependenciesScope.of(context).files
context, .pick(label: l10n.pdfFileType, extensions: const ['pdf']);
).files.pick(label: l10n.pdfFileType, extensions: const ['pdf']);
if (file != null) { if (file != null) {
bloc.add( bloc.add(
PdfListEventImportRequested(bytes: file.bytes, fileName: file.name), PdfListEventImportRequested(bytes: file.bytes, fileName: file.name),
@ -1542,10 +1533,10 @@ enum _Import() {
Future<void> _importPath(BuildContext context) async { Future<void> _importPath(BuildContext context) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final bloc = context.read<PathListBloc>(); final bloc = context.read<PathListBloc>();
final tabs = context.read<ContentTabCubit>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
final picked = await DependenciesScope.of( final picked = await DependenciesScope.of(context).files
context, .pickMany(label: l10n.pathImportFileType, extensions: const ['json']);
).files.pickMany(label: l10n.pathImportFileType, extensions: const ['json']);
final files = <PathFile>[]; final files = <PathFile>[];
for (final file in picked..sort((a, b) => a.name.compareTo(b.name))) { for (final file in picked..sort((a, b) => a.name.compareTo(b.name))) {
try { try {
@ -1567,6 +1558,8 @@ Future<void> _importPath(BuildContext context) async {
} }
} }
if (files.isNotEmpty) { if (files.isNotEmpty) {
// The paths show as they come in, below how far the import has come.
tabs.show(StudysetTab.paths);
bloc.add(PathListEventImportRequested(files)); bloc.add(PathListEventImportRequested(files));
} }
} }

View file

@ -175,6 +175,42 @@ void main() {
await pumpEventQueue(); await pumpEventQueue();
}); });
test('says how far an import has come: the path being stored, which of '
'how many, and the share stored, path and steps alike', () async {
await until((s) => s.loaded);
final progress = <PathImportProgress?>[];
final listening = bloc.stream.listen(
(state) => progress.add(state.importProgress),
);
addTearDown(listening.cancel);
String path(String title) =>
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}, {"title": "More", "type": "info"}]}}';
bloc.add(
PathListEventImportRequested([
(name: '01.json', source: path('State-of-being')),
(name: '02.json', source: path('Particles')),
]),
);
await until((s) => s.importResult != null);
await pumpEventQueue();
final reported = progress.nonNulls.toList();
// Reading first, then each path with its steps: six shares.
expect(reported.first, (title: '', path: 0, paths: 2, done: 0.0));
expect(
{for (final p in reported.skip(1)) (p.path, p.title)},
{(1, 'State-of-being'), (2, 'Particles')},
);
expect(
{for (final p in reported) p.done},
{0.0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1.0},
);
expect(bloc.state.importProgress, isNull);
expect(bloc.state.importing, isFalse);
});
test('creating a path hands back its id, and lists it', () async { test('creating a path hands back its id, and lists it', () async {
await until((s) => s.loaded); await until((s) => s.loaded);

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
@ -12,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -289,6 +291,54 @@ void main() {
); );
}); });
testWidgets('an import shows the paths tab at once, and how far it has '
'come above the paths it adds', (tester) async {
Uint8List path(String title) => utf8.encode(
'{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}]}}',
);
files.nextMany = [
(name: '01.json', bytes: path('State-of-being')),
(name: '02.json', bytes: path('Particles')),
];
final hold = Completer<void>();
pathGateway.holdSteps = hold;
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Flashcards').first);
await tester.pumpAndSettle();
expect(shownTab(tester), isNot(0));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning paths'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(shownTab(tester), 0);
expect(
find.text('Importing path 1 of 2: “State-of-being”…'),
findsOneWidget,
);
expect(find.byType(LinearProgressIndicator), findsOneWidget);
pathGateway.holdSteps = null;
hold.complete();
await tester.pumpAndSettle();
expect(find.byType(LinearProgressIndicator), findsNothing);
expect(
find.textContaining('Imported 2 paths with 2 steps.'),
findsOneWidget,
);
});
testWidgets('a note written from Add material shows in the Notes tab on ' testWidgets('a note written from Add material shows in the Notes tab on '
'coming back, as flashcards do in theirs', (tester) async { 'coming back, as flashcards do in theirs', (tester) async {
final paths = repository(); final paths = repository();
@ -354,6 +404,46 @@ void main() {
expect(find.text('2 steps complete.'), findsOneWidget); expect(find.text('2 steps complete.'), findsOneWidget);
}); });
group('finishing a path', () {
final confetti = find.descendant(
of: find.byType(ConfettiBurst),
matching: find.byType(CustomPaint),
);
Future<void> finish(WidgetTester tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.path('japanese', id));
await tester.tap(find.widgetWithText(FilledButton, 'Finish'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('You finished this path'), findsOneWidget);
}
testWidgets('bursts confetti once, which then clears', (tester) async {
await finish(tester);
expect(confetti, findsOneWidget);
// It ends by itself.
await tester.pumpAndSettle();
expect(confetti, findsNothing);
});
testWidgets('bursts no confetti with animations switched off on the '
'device', (tester) async {
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(
tester.platformDispatcher.clearAccessibilityFeaturesTestValue,
);
await finish(tester);
expect(confetti, findsNothing);
});
});
testWidgets('on a phone, the way on is one wide button in the middle at ' testWidgets('on a phone, the way on is one wide button in the middle at '
'the bottom, in the same place on every step; Back beside it once ' 'the bottom, in the same place on every step; Back beside it once '
'there is a step behind', (tester) async { 'there is a step behind', (tester) async {

View file

@ -18,6 +18,10 @@ class FakeLearningPathGateway() implements LearningPathGateway {
/// Every step revision written on this device, in order. /// Every step revision written on this device, in order.
final written = <StepRevisionRow>[]; final written = <StepRevisionRow>[];
/// While set, a new step waits for it to complete before it is stored, as
/// on a slow device: what shows while steps come in can be seen.
Completer<void>? holdSteps;
/// The deletion steps, in order: `blank step <id>`, `blank path <id>`, /// The deletion steps, in order: `blank step <id>`, `blank path <id>`,
/// `delete step <id>`, `delete path <id>`. /// `delete step <id>`, `delete path <id>`.
final deletions = <String>[]; final deletions = <String>[];
@ -118,6 +122,7 @@ class FakeLearningPathGateway() implements LearningPathGateway {
required String kind, required String kind,
required ByteData payload, required ByteData payload,
}) async { }) async {
await holdSteps?.future;
final id = _newId('step'); final id = _newId('step');
_steps[id] = (id: id, pathId: pathId, position: position, kind: kind); _steps[id] = (id: id, pathId: pathId, position: position, kind: kind);
final revisionId = await insertRevision( final revisionId = await insertRevision(