feat(app): every empty studyset tab says how to add some, as Files did

Paths, flashcards and notes now show what Files showed when empty: an
icon for what the tab holds, "No … yet", and the menus that add some
("Add material → Create a path", "⋮ → Import content", …), through one
shared widget, EmptyListMessage. The icons are those the app uses for
each already: the route, the study screen's card stack, the note pen,
the folder.

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-08 20:24:22 +02:00
commit 02efa2cdcf
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 139 additions and 102 deletions

View file

@ -97,7 +97,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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. - **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, 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 stays empty until something is shared. - **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, 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 stays empty until something is shared.
- **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. `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. 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` and `SliverCentered`, 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. `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. 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. - **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.
- **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 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 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.
- **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

@ -76,7 +76,7 @@
"pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}", "pathImportFailed": "Diese Datei konnte nicht importiert werden. {reason}",
"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",
"pathsEmptyHint": "Ein Pfad führt Schritt für Schritt durch eine Lektion.", "pathsEmptyHint": "Ein Pfad führt Schritt für Schritt durch eine Lektion. Nutze „Material hinzufügen → Pfad erstellen“, um einen zu schreiben, oder „⋮ → Inhalt importieren“, um einen zu importieren.",
"pathOptions": "Pfadoptionen", "pathOptions": "Pfadoptionen",
"pathEdit": "Bearbeiten", "pathEdit": "Bearbeiten",
"pathResetProgress": "Fortschritt zurücksetzen", "pathResetProgress": "Fortschritt zurücksetzen",
@ -199,7 +199,7 @@
"studysetCountPaths": "{count, plural, =1{1 Pfad} other{{count} Pfade}}", "studysetCountPaths": "{count, plural, =1{1 Pfad} other{{count} Pfade}}",
"studysetCreateNote": "Notiz erstellen", "studysetCreateNote": "Notiz erstellen",
"notesEmpty": "Noch keine Notizen", "notesEmpty": "Noch keine Notizen",
"notesEmptyHint": "Notizen erscheinen hier, sobald du welche hinzufügst.", "notesEmptyHint": "Nutze „Material hinzufügen → Notiz erstellen“, um eine zu schreiben.",
"noteUntitled": "Unbenannte Notiz", "noteUntitled": "Unbenannte Notiz",
"noteEdited": "Bearbeitet {when}", "noteEdited": "Bearbeitet {when}",
"timeJustNow": "gerade eben", "timeJustNow": "gerade eben",
@ -225,7 +225,7 @@
"noteAddTags": "Tags hinzufügen", "noteAddTags": "Tags hinzufügen",
"noteEditTags": "Tags bearbeiten", "noteEditTags": "Tags bearbeiten",
"studysetFlashcardsEmpty": "Noch keine Karteikarten", "studysetFlashcardsEmpty": "Noch keine Karteikarten",
"studysetFlashcardsEmptyHint": "Füge welche hinzu, um mit dem Lernen zu beginnen.", "studysetFlashcardsEmptyHint": "Nutze „Material hinzufügen → Karteikarten erstellen“, um welche zu schreiben, oder „⋮ → Inhalt importieren“ für ein Anki-Deck oder eine Liste.",
"studysetAddMaterial": "Material hinzufügen", "studysetAddMaterial": "Material hinzufügen",
"studysetCreateFlashcards": "Karteikarten erstellen", "studysetCreateFlashcards": "Karteikarten erstellen",
"studysetStudy": "Lernen", "studysetStudy": "Lernen",

View file

@ -85,7 +85,7 @@
"@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}}, "@pathImportFailed": {"placeholders": {"reason": {"type": "String"}}},
"pathImportUnreadable": "It isn't text in UTF-8.", "pathImportUnreadable": "It isn't text in UTF-8.",
"pathsEmpty": "No paths yet", "pathsEmpty": "No paths yet",
"pathsEmptyHint": "A path leads through a lesson, step by step.", "pathsEmptyHint": "A path leads through a lesson, step by step. Use \"Add material → Create a path\" to write one, or \"⋮ → Import content\" to import one.",
"pathOptions": "Path options", "pathOptions": "Path options",
"pathEdit": "Edit", "pathEdit": "Edit",
"pathResetProgress": "Reset progress", "pathResetProgress": "Reset progress",
@ -217,7 +217,7 @@
"@studysetCountPaths": {"placeholders": {"count": {"type": "int"}}}, "@studysetCountPaths": {"placeholders": {"count": {"type": "int"}}},
"studysetCreateNote": "Create a note", "studysetCreateNote": "Create a note",
"notesEmpty": "No notes yet", "notesEmpty": "No notes yet",
"notesEmptyHint": "Notes will appear here when you add them.", "notesEmptyHint": "Use \"Add material → Create a note\" to write one.",
"noteUntitled": "Untitled note", "noteUntitled": "Untitled note",
"noteEdited": "Edited {when}", "noteEdited": "Edited {when}",
"@noteEdited": {"placeholders": {"when": {"type": "String"}}}, "@noteEdited": {"placeholders": {"when": {"type": "String"}}},
@ -247,7 +247,7 @@
"noteAddTags": "Add tags", "noteAddTags": "Add tags",
"noteEditTags": "Edit tags", "noteEditTags": "Edit tags",
"studysetFlashcardsEmpty": "No flashcards yet", "studysetFlashcardsEmpty": "No flashcards yet",
"studysetFlashcardsEmptyHint": "Add some to start studying.", "studysetFlashcardsEmptyHint": "Use \"Add material → Create flashcards\" to write some, or \"⋮ → Import content\" to bring in an Anki deck or a list.",
"studysetAddMaterial": "Add material", "studysetAddMaterial": "Add material",
"studysetCreateFlashcards": "Create flashcards", "studysetCreateFlashcards": "Create flashcards",
"studysetStudy": "Study", "studysetStudy": "Study",

View file

@ -517,7 +517,7 @@ abstract class AppLocalizations {
/// No description provided for @pathsEmptyHint. /// No description provided for @pathsEmptyHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'A path leads through a lesson, step by step.'** /// **'A path leads through a lesson, step by step. Use \"Add material → Create a path\" to write one, or \"⋮ → Import content\" to import one.'**
String get pathsEmptyHint; String get pathsEmptyHint;
/// No description provided for @pathOptions. /// No description provided for @pathOptions.
@ -1255,7 +1255,7 @@ abstract class AppLocalizations {
/// No description provided for @notesEmptyHint. /// No description provided for @notesEmptyHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Notes will appear here when you add them.'** /// **'Use \"Add material → Create a note\" to write one.'**
String get notesEmptyHint; String get notesEmptyHint;
/// No description provided for @noteUntitled. /// No description provided for @noteUntitled.
@ -1411,7 +1411,7 @@ abstract class AppLocalizations {
/// No description provided for @studysetFlashcardsEmptyHint. /// No description provided for @studysetFlashcardsEmptyHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Add some to start studying.'** /// **'Use \"Add material → Create flashcards\" to write some, or \"⋮ → Import content\" to bring in an Anki deck or a list.'**
String get studysetFlashcardsEmptyHint; String get studysetFlashcardsEmptyHint;
/// No description provided for @studysetAddMaterial. /// No description provided for @studysetAddMaterial.

View file

@ -298,7 +298,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get pathsEmptyHint => String get pathsEmptyHint =>
'Ein Pfad führt Schritt für Schritt durch eine Lektion.'; 'Ein Pfad führt Schritt für Schritt durch eine Lektion. Nutze „Material hinzufügen → Pfad erstellen“, um einen zu schreiben, oder „⋮ → Inhalt importieren“, um einen zu importieren.';
@override @override
String get pathOptions => 'Pfadoptionen'; String get pathOptions => 'Pfadoptionen';
@ -717,7 +717,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get notesEmptyHint => String get notesEmptyHint =>
'Notizen erscheinen hier, sobald du welche hinzufügst.'; 'Nutze „Material hinzufügen → Notiz erstellen“, um eine zu schreiben.';
@override @override
String get noteUntitled => 'Unbenannte Notiz'; String get noteUntitled => 'Unbenannte Notiz';
@ -824,7 +824,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get studysetFlashcardsEmptyHint => String get studysetFlashcardsEmptyHint =>
'Füge welche hinzu, um mit dem Lernen zu beginnen.'; 'Nutze „Material hinzufügen → Karteikarten erstellen“, um welche zu schreiben, oder „⋮ → Inhalt importieren“ für ein Anki-Deck oder eine Liste.';
@override @override
String get studysetAddMaterial => 'Material hinzufügen'; String get studysetAddMaterial => 'Material hinzufügen';

View file

@ -295,7 +295,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get pathsEmpty => 'No paths yet'; String get pathsEmpty => 'No paths yet';
@override @override
String get pathsEmptyHint => 'A path leads through a lesson, step by step.'; String get pathsEmptyHint =>
'A path leads through a lesson, step by step. Use \"Add material → Create a path\" to write one, or \"⋮ → Import content\" to import one.';
@override @override
String get pathOptions => 'Path options'; String get pathOptions => 'Path options';
@ -713,7 +714,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get notesEmpty => 'No notes yet'; String get notesEmpty => 'No notes yet';
@override @override
String get notesEmptyHint => 'Notes will appear here when you add them.'; String get notesEmptyHint =>
'Use \"Add material → Create a note\" to write one.';
@override @override
String get noteUntitled => 'Untitled note'; String get noteUntitled => 'Untitled note';
@ -819,7 +821,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get studysetFlashcardsEmpty => 'No flashcards yet'; String get studysetFlashcardsEmpty => 'No flashcards yet';
@override @override
String get studysetFlashcardsEmptyHint => 'Add some to start studying.'; String get studysetFlashcardsEmptyHint =>
'Use \"Add material → Create flashcards\" to write some, or \"⋮ → Import content\" to bring in an Anki deck or a list.';
@override @override
String get studysetAddMaterial => 'Add material'; String get studysetAddMaterial => 'Add material';

View file

@ -0,0 +1,44 @@
import 'package:material_ui/material_ui.dart';
/// What a list shows while it holds nothing yet, every tab of a studyset
/// alike: an [icon] for what it lists, a [title] saying there is none yet,
/// and a [hint] saying how to add some.
class const EmptyListMessage({
/// What the list holds, e.g. a folder for files.
required final IconData icon,
/// That there is nothing yet, e.g. "No files yet".
required final String title,
/// How to add something, naming the menus to go through.
required final String hint,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 40, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
hint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.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/card/bloc/card_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
@ -79,7 +80,11 @@ class const SliverCardList({
), ),
CardListStateLoaded(cards: []) => SliverCentered( CardListStateLoaded(cards: []) => SliverCentered(
padding: padding, padding: padding,
child: const _NoCards(), child: EmptyListMessage(
icon: LucideIcons.layers,
title: AppLocalizations.of(context).studysetFlashcardsEmpty,
hint: AppLocalizations.of(context).studysetFlashcardsEmptyHint,
),
), ),
final CardListStateLoaded loaded => final CardListStateLoaded loaded =>
loaded.shown.isEmpty loaded.shown.isEmpty
@ -243,35 +248,6 @@ class const _NothingTagged() extends StatelessWidget {
} }
} }
/// No cards yet, and where they come from.
class const _NoCards() extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.studysetFlashcardsEmpty,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
l10n.studysetFlashcardsEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class const _SliverCards( class const _SliverCards(
final CardRepository repository, final CardRepository repository,
final String studysetId, final String studysetId,

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart'; import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart'; import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
@ -37,7 +38,6 @@ class const SliverPathList({
BlocBuilder<PathListBloc, PathListState>( BlocBuilder<PathListBloc, PathListState>(
builder: (context, state) { builder: (context, state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (!state.loaded) { if (!state.loaded) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,
@ -47,22 +47,10 @@ class const SliverPathList({
if (state.paths.isEmpty) { if (state.paths.isEmpty) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,
child: Padding( child: EmptyListMessage(
padding: const EdgeInsets.all(16), icon: LucideIcons.route,
child: Column( title: l10n.pathsEmpty,
mainAxisSize: MainAxisSize.min, hint: l10n.pathsEmptyHint,
children: [
Text(l10n.pathsEmpty, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.pathsEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
), ),
); );
} }

View file

@ -8,6 +8,7 @@ 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/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart'; import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart'; import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
@ -37,7 +38,6 @@ class const SliverNoteList({
BlocBuilder<NoteListBloc, NoteListState>( BlocBuilder<NoteListBloc, NoteListState>(
builder: (context, state) { builder: (context, state) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (!state.loaded) { if (!state.loaded) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,
@ -47,22 +47,10 @@ class const SliverNoteList({
if (state.notes.isEmpty) { if (state.notes.isEmpty) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,
child: Padding( child: EmptyListMessage(
padding: const EdgeInsets.all(24), icon: LucideIcons.notebookPen,
child: Column( title: l10n.notesEmpty,
mainAxisSize: MainAxisSize.min, hint: l10n.notesEmptyHint,
children: [
Text(l10n.notesEmpty, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.notesEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
), ),
); );
} }

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart'; import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart'; import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart'; import 'package:halkyon_learn_flutter/src/core/widget/lift_while_dragged.dart';
import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/more_info_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart'; import 'package:halkyon_learn_flutter/src/core/widget/reorder_grip.dart';
@ -54,28 +55,10 @@ class const SliverPdfList({
if (state.rows.isEmpty) { if (state.rows.isEmpty) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,
child: Padding( child: EmptyListMessage(
padding: const EdgeInsets.all(24), icon: LucideIcons.folder,
child: Column( title: l10n.filesEmpty,
mainAxisSize: MainAxisSize.min, hint: l10n.filesEmptyHint,
children: [
Icon(
LucideIcons.folder,
size: 40,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(l10n.filesEmpty, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(
l10n.filesEmptyHint,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
), ),
); );
} }

View file

@ -3,6 +3,7 @@ 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/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart'; import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart'; import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
@ -19,6 +20,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../../../support/fake_auth_gateway.dart'; import '../../../support/fake_auth_gateway.dart';
@ -463,6 +465,59 @@ void main() {
expect(find.byTooltip('Drag to reorder'), findsNothing); expect(find.byTooltip('Drag to reorder'), findsNothing);
}); });
testWidgets('every empty tab shows its icon, that there is nothing yet, '
'and how to add some', (tester) async {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
for (final (tab, icon, title, how) in [
(
'Flashcards',
LucideIcons.layers,
'No flashcards yet',
'"Add material → Create flashcards"',
),
(
'Paths',
LucideIcons.route,
'No paths yet',
'"Add material → Create a path"',
),
(
'Notes',
LucideIcons.notebookPen,
'No notes yet',
'"Add material → Create a note"',
),
(
'Files',
LucideIcons.folder,
'No files yet',
'"Add material → Upload a PDF"',
),
]) {
await tester.tap(find.text(tab));
await tester.pumpAndSettle();
final message = find.byType(EmptyListMessage);
expect(message, findsOneWidget, reason: tab);
expect(
find.descendant(of: message, matching: find.byIcon(icon)),
findsOneWidget,
reason: tab,
);
expect(
find.descendant(of: message, matching: find.text(title)),
findsOneWidget,
reason: tab,
);
expect(
find.descendant(of: message, matching: find.textContaining(how)),
findsOneWidget,
reason: tab,
);
}
});
testWidgets('with two notes or more, Reorder beside the tabs lets them be ' testWidgets('with two notes or more, Reorder beside the tabs lets them be '
'dragged into another order, until Done', (tester) async { 'dragged into another order, until Done', (tester) async {
stored('japanese', 'Japanese'); stored('japanese', 'Japanese');