feat(app): the studyset screen laid out as before
The Study pill reads "Learn 12 flashcards" in the app bar and "Learn 12"
floating, "Study" with nothing due, and is off with nothing to study,
saying so. With a path not finished, a chevron beside it offers to start
or continue the path: a menu in the app bar, a Study mode sheet on
phones. The floating bar rises above the licence line, which takes two
lines at most.
Add material offers a path, flashcards, a note and a PDF, in that order;
imports moved to the options' Import content, which asks for a learning
path or an Anki deck. The options read Edit, Move, Study options, Import
content, Reset progress, Archive, Delete. The bibliography is an outlined
round button above the content, and on phones at the foot of the topics.
The tabs sit on a line of their own across the card, what the tab shown
offers beside them: the flashcards' filter and options, and on the paths
Reorder once there are two, which turns them into handles to drag. An
empty tab says so in its middle; tapping the open tab again returns to
its top, and pulling a tab down syncs. The breadcrumb's steps are plain
links between "›", the studyset itself bold. A long description is cut at
a fixed height, measured, with Read more opening it whole under the
studyset's title. A studyset that no longer exists leads back to the
library.
The topics: All topics on the primary colour, in view also while they
are rearranged; each topic with the study it invites in a round icon,
what it holds ("Empty" with nothing, files counted too), and its options
(now with Reset progress) behind "…" or a long press where the finger
is; New sub-studyset at the foot below a line, the list with its
scrollbar always shown. All topics opens Add material while nothing is
due.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aa69be47d1
commit
72b29c7e80
23 changed files with 1949 additions and 616 deletions
|
|
@ -48,6 +48,7 @@
|
|||
"libraryRefresh": "Aktualisieren",
|
||||
"libraryRetry": "Erneut versuchen",
|
||||
"studysetNoContent": "Noch kein Inhalt",
|
||||
"studysetEmpty": "Leer",
|
||||
"studysetActionReview": "Wiederholen",
|
||||
"studysetActionStudy": "Lernen",
|
||||
"studysetActionAdd": "Inhalt hinzufügen",
|
||||
|
|
@ -225,6 +226,23 @@
|
|||
"studysetCreateFlashcards": "Karteikarten erstellen",
|
||||
"studysetStudy": "Lernen",
|
||||
"studysetLearnCount": "{count} lernen",
|
||||
"studysetLearnFlashcards": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
||||
"studysetNothingToStudy": "Noch nichts zu lernen",
|
||||
"studysetStudyMode": "Lernmodus",
|
||||
"studysetStartPath": "Starten: {title}",
|
||||
"studysetContinuePath": "Fortsetzen: {title}",
|
||||
"studysetStartPathHint": "Beginne mit dem ersten Schritt dieses Pfads.",
|
||||
"studysetContinuePathHint": "Mach beim nächsten Pfadschritt weiter, wo du aufgehört hast.",
|
||||
"studysetSpacedRepetition": "Verteilte Wiederholung",
|
||||
"studysetSpacedRepetitionHint": "Wiederhole die fälligen Karten, geplant durch FSRS.",
|
||||
"studysetImportContent": "Inhalt importieren",
|
||||
"studysetImportPath": "Lernpfad",
|
||||
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
|
||||
"studysetImportAnki": "Anki-Deck",
|
||||
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
|
||||
"studysetReorder": "Umsortieren",
|
||||
"studysetReorderPaths": "Pfade umsortieren",
|
||||
"studysetReorderDone": "Fertig",
|
||||
"studysetOpenTopics": "Themen anzeigen",
|
||||
"studysetTopics": "Themen",
|
||||
"studysetAllTopics": "Alle Themen",
|
||||
|
|
@ -670,6 +688,7 @@
|
|||
"personDataFailed": "Die Daten dieses Geräts konnten nicht geöffnet werden.",
|
||||
"sessionUnreachable": "Der Server war nicht erreichbar.",
|
||||
"notFoundGone": "Das gibt es nicht mehr. Vielleicht wurde es auf einem anderen Gerät gelöscht.",
|
||||
"studysetBackToLibrary": "Zurück zur Bibliothek",
|
||||
"fileImage": "Bild",
|
||||
"fileSound": "Audio",
|
||||
"fileImageUnavailable": "Dieses Bild ist noch nicht auf diesem Gerät. Es erscheint, sobald es hochgeladen ist und du online bist.",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"libraryRefresh": "Refresh",
|
||||
"libraryRetry": "Retry",
|
||||
"studysetNoContent": "No content yet",
|
||||
"studysetEmpty": "Empty",
|
||||
"studysetActionReview": "Review",
|
||||
"studysetActionStudy": "Study",
|
||||
"studysetActionAdd": "Add content",
|
||||
|
|
@ -250,6 +251,26 @@
|
|||
"studysetStudy": "Study",
|
||||
"studysetLearnCount": "Learn {count}",
|
||||
"@studysetLearnCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetLearnFlashcards": "{count, plural, =1{Learn 1 flashcard} other{Learn {count} flashcards}}",
|
||||
"@studysetLearnFlashcards": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetNothingToStudy": "Nothing to study yet",
|
||||
"studysetStudyMode": "Study mode",
|
||||
"studysetStartPath": "Start: {title}",
|
||||
"@studysetStartPath": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetContinuePath": "Continue: {title}",
|
||||
"@studysetContinuePath": {"placeholders": {"title": {"type": "String"}}},
|
||||
"studysetStartPathHint": "Begin the first step of this path.",
|
||||
"studysetContinuePathHint": "Pick up the next path step where you left off.",
|
||||
"studysetSpacedRepetition": "Spaced repetition",
|
||||
"studysetSpacedRepetitionHint": "Review the cards that are due, scheduled by FSRS.",
|
||||
"studysetImportContent": "Import content",
|
||||
"studysetImportPath": "Learning path",
|
||||
"studysetImportPathHint": "From a Halkyon path JSON file",
|
||||
"studysetImportAnki": "Anki deck",
|
||||
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
|
||||
"studysetReorder": "Reorder",
|
||||
"studysetReorderPaths": "Reorder paths",
|
||||
"studysetReorderDone": "Done",
|
||||
"studysetOpenTopics": "Show topics",
|
||||
"studysetTopics": "Topics",
|
||||
"studysetAllTopics": "All topics",
|
||||
|
|
@ -752,6 +773,7 @@
|
|||
"personDataFailed": "Couldn't open this device's data.",
|
||||
"sessionUnreachable": "Couldn't reach the server.",
|
||||
"notFoundGone": "This no longer exists. It may have been deleted on another device.",
|
||||
"studysetBackToLibrary": "Back to library",
|
||||
"fileImage": "Image",
|
||||
"fileSound": "Sound",
|
||||
"fileImageUnavailable": "This image isn't on this device yet. It shows once it's uploaded and you're online.",
|
||||
|
|
|
|||
|
|
@ -346,6 +346,12 @@ abstract class AppLocalizations {
|
|||
/// **'No content yet'**
|
||||
String get studysetNoContent;
|
||||
|
||||
/// No description provided for @studysetEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Empty'**
|
||||
String get studysetEmpty;
|
||||
|
||||
/// No description provided for @studysetActionReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1408,6 +1414,108 @@ abstract class AppLocalizations {
|
|||
/// **'Learn {count}'**
|
||||
String studysetLearnCount(int count);
|
||||
|
||||
/// No description provided for @studysetLearnFlashcards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Learn 1 flashcard} other{Learn {count} flashcards}}'**
|
||||
String studysetLearnFlashcards(int count);
|
||||
|
||||
/// No description provided for @studysetNothingToStudy.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing to study yet'**
|
||||
String get studysetNothingToStudy;
|
||||
|
||||
/// No description provided for @studysetStudyMode.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study mode'**
|
||||
String get studysetStudyMode;
|
||||
|
||||
/// No description provided for @studysetStartPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Start: {title}'**
|
||||
String studysetStartPath(String title);
|
||||
|
||||
/// No description provided for @studysetContinuePath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue: {title}'**
|
||||
String studysetContinuePath(String title);
|
||||
|
||||
/// No description provided for @studysetStartPathHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Begin the first step of this path.'**
|
||||
String get studysetStartPathHint;
|
||||
|
||||
/// No description provided for @studysetContinuePathHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pick up the next path step where you left off.'**
|
||||
String get studysetContinuePathHint;
|
||||
|
||||
/// No description provided for @studysetSpacedRepetition.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Spaced repetition'**
|
||||
String get studysetSpacedRepetition;
|
||||
|
||||
/// No description provided for @studysetSpacedRepetitionHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Review the cards that are due, scheduled by FSRS.'**
|
||||
String get studysetSpacedRepetitionHint;
|
||||
|
||||
/// No description provided for @studysetImportContent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import content'**
|
||||
String get studysetImportContent;
|
||||
|
||||
/// No description provided for @studysetImportPath.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learning path'**
|
||||
String get studysetImportPath;
|
||||
|
||||
/// No description provided for @studysetImportPathHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From a Halkyon path JSON file'**
|
||||
String get studysetImportPathHint;
|
||||
|
||||
/// No description provided for @studysetImportAnki.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Anki deck'**
|
||||
String get studysetImportAnki;
|
||||
|
||||
/// No description provided for @studysetImportAnkiHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'From an .apkg or .colpkg file'**
|
||||
String get studysetImportAnkiHint;
|
||||
|
||||
/// No description provided for @studysetReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder'**
|
||||
String get studysetReorder;
|
||||
|
||||
/// No description provided for @studysetReorderPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder paths'**
|
||||
String get studysetReorderPaths;
|
||||
|
||||
/// No description provided for @studysetReorderDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get studysetReorderDone;
|
||||
|
||||
/// No description provided for @studysetOpenTopics.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1444,18 +1552,6 @@ abstract class AppLocalizations {
|
|||
/// **'Break this studyset into milestones or lessons.'**
|
||||
String get studysetSubStudysetsEmptyHint;
|
||||
|
||||
/// No description provided for @studysetReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reorder'**
|
||||
String get studysetReorder;
|
||||
|
||||
/// No description provided for @studysetReorderDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Done'**
|
||||
String get studysetReorderDone;
|
||||
|
||||
/// No description provided for @studysetDragToReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -4012,6 +4108,12 @@ abstract class AppLocalizations {
|
|||
/// **'This no longer exists. It may have been deleted on another device.'**
|
||||
String get notFoundGone;
|
||||
|
||||
/// No description provided for @studysetBackToLibrary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Back to library'**
|
||||
String get studysetBackToLibrary;
|
||||
|
||||
/// No description provided for @fileImage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -191,6 +191,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetNoContent => 'Noch kein Inhalt';
|
||||
|
||||
@override
|
||||
String get studysetEmpty => 'Leer';
|
||||
|
||||
@override
|
||||
String get studysetActionReview => 'Wiederholen';
|
||||
|
||||
|
|
@ -827,6 +830,72 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$count lernen';
|
||||
}
|
||||
|
||||
@override
|
||||
String studysetLearnFlashcards(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Karteikarten lernen',
|
||||
one: '1 Karteikarte lernen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetNothingToStudy => 'Noch nichts zu lernen';
|
||||
|
||||
@override
|
||||
String get studysetStudyMode => 'Lernmodus';
|
||||
|
||||
@override
|
||||
String studysetStartPath(String title) {
|
||||
return 'Starten: $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String studysetContinuePath(String title) {
|
||||
return 'Fortsetzen: $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetStartPathHint =>
|
||||
'Beginne mit dem ersten Schritt dieses Pfads.';
|
||||
|
||||
@override
|
||||
String get studysetContinuePathHint =>
|
||||
'Mach beim nächsten Pfadschritt weiter, wo du aufgehört hast.';
|
||||
|
||||
@override
|
||||
String get studysetSpacedRepetition => 'Verteilte Wiederholung';
|
||||
|
||||
@override
|
||||
String get studysetSpacedRepetitionHint =>
|
||||
'Wiederhole die fälligen Karten, geplant durch FSRS.';
|
||||
|
||||
@override
|
||||
String get studysetImportContent => 'Inhalt importieren';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Lernpfad';
|
||||
|
||||
@override
|
||||
String get studysetImportPathHint => 'Aus einer Halkyon-Pfad-JSON-Datei';
|
||||
|
||||
@override
|
||||
String get studysetImportAnki => 'Anki-Deck';
|
||||
|
||||
@override
|
||||
String get studysetImportAnkiHint => 'Aus einer .apkg- oder .colpkg-Datei';
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Umsortieren';
|
||||
|
||||
@override
|
||||
String get studysetReorderPaths => 'Pfade umsortieren';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String get studysetOpenTopics => 'Themen anzeigen';
|
||||
|
||||
|
|
@ -846,12 +915,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get studysetSubStudysetsEmptyHint =>
|
||||
'Teile dieses Lernset in Meilensteine oder Lektionen auf.';
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Umsortieren';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Fertig';
|
||||
|
||||
@override
|
||||
String get studysetDragToReorder => 'Zum Umsortieren ziehen';
|
||||
|
||||
|
|
@ -2430,6 +2493,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get notFoundGone =>
|
||||
'Das gibt es nicht mehr. Vielleicht wurde es auf einem anderen Gerät gelöscht.';
|
||||
|
||||
@override
|
||||
String get studysetBackToLibrary => 'Zurück zur Bibliothek';
|
||||
|
||||
@override
|
||||
String get fileImage => 'Bild';
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetNoContent => 'No content yet';
|
||||
|
||||
@override
|
||||
String get studysetEmpty => 'Empty';
|
||||
|
||||
@override
|
||||
String get studysetActionReview => 'Review';
|
||||
|
||||
|
|
@ -822,6 +825,71 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Learn $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String studysetLearnFlashcards(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Learn $count flashcards',
|
||||
one: 'Learn 1 flashcard',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetNothingToStudy => 'Nothing to study yet';
|
||||
|
||||
@override
|
||||
String get studysetStudyMode => 'Study mode';
|
||||
|
||||
@override
|
||||
String studysetStartPath(String title) {
|
||||
return 'Start: $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String studysetContinuePath(String title) {
|
||||
return 'Continue: $title';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetStartPathHint => 'Begin the first step of this path.';
|
||||
|
||||
@override
|
||||
String get studysetContinuePathHint =>
|
||||
'Pick up the next path step where you left off.';
|
||||
|
||||
@override
|
||||
String get studysetSpacedRepetition => 'Spaced repetition';
|
||||
|
||||
@override
|
||||
String get studysetSpacedRepetitionHint =>
|
||||
'Review the cards that are due, scheduled by FSRS.';
|
||||
|
||||
@override
|
||||
String get studysetImportContent => 'Import content';
|
||||
|
||||
@override
|
||||
String get studysetImportPath => 'Learning path';
|
||||
|
||||
@override
|
||||
String get studysetImportPathHint => 'From a Halkyon path JSON file';
|
||||
|
||||
@override
|
||||
String get studysetImportAnki => 'Anki deck';
|
||||
|
||||
@override
|
||||
String get studysetImportAnkiHint => 'From an .apkg or .colpkg file';
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Reorder';
|
||||
|
||||
@override
|
||||
String get studysetReorderPaths => 'Reorder paths';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Done';
|
||||
|
||||
@override
|
||||
String get studysetOpenTopics => 'Show topics';
|
||||
|
||||
|
|
@ -841,12 +909,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get studysetSubStudysetsEmptyHint =>
|
||||
'Break this studyset into milestones or lessons.';
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Reorder';
|
||||
|
||||
@override
|
||||
String get studysetReorderDone => 'Done';
|
||||
|
||||
@override
|
||||
String get studysetDragToReorder => 'Drag to reorder';
|
||||
|
||||
|
|
@ -2411,6 +2473,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get notFoundGone =>
|
||||
'This no longer exists. It may have been deleted on another device.';
|
||||
|
||||
@override
|
||||
String get studysetBackToLibrary => 'Back to library';
|
||||
|
||||
@override
|
||||
String get fileImage => 'Image';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
|
|
@ -264,6 +265,10 @@ Widget _stepEditor(
|
|||
Widget _studysetScreen(BuildContext context, String id) => FoundById<Studyset>(
|
||||
id: id,
|
||||
items: PersonScope.of(context).studysets.watchAll,
|
||||
wayBack: (
|
||||
label: AppLocalizations.of(context).studysetBackToLibrary,
|
||||
location: RoutePaths.library,
|
||||
),
|
||||
idOf: (studyset) => studyset.id,
|
||||
builder: (studyset, all) => StudysetScreen(studyset: studyset, all: all),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ class const ContentCountsLine({
|
|||
|
||||
/// Shown when nothing is counted; nothing shows while it is empty.
|
||||
final String placeholder = '',
|
||||
|
||||
/// Between two counts.
|
||||
final String separator = ' • ',
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// Between two counts.
|
||||
static const separator = ' • ';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -5,7 +6,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// Builds a screen for the item a route's [id] names, found in a live list of
|
||||
/// items: a spinner until the first list arrives, and a "no longer exists"
|
||||
/// screen while the list has no such item (deleted, perhaps on another
|
||||
/// device).
|
||||
/// device), with [wayBack] below it if given.
|
||||
///
|
||||
/// A screen that closes as its item goes, having deleted it, keeps showing
|
||||
/// the item on its way out.
|
||||
|
|
@ -21,6 +22,9 @@ class const FoundById<T extends Object>({
|
|||
|
||||
/// The screen for the item found, given the whole list too.
|
||||
required final Widget Function(T item, List<T> items) builder,
|
||||
|
||||
/// Where the "no longer exists" screen leads, and what its button says.
|
||||
final ({String label, String location})? wayBack,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -58,21 +62,35 @@ class _FoundByIdState<T extends Object>() extends State<FoundById<T>> {
|
|||
if (found != null && !(ModalRoute.of(context)?.isActive ?? true)) {
|
||||
return widget.builder(found, _foundIn);
|
||||
}
|
||||
return const _Gone();
|
||||
return _Gone(wayBack: widget.wayBack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class const _Gone() extends StatelessWidget {
|
||||
class const _Gone({required final ({String label, String location})? wayBack})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final way = wayBack;
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(l10n.notFoundGone, textAlign: TextAlign.center),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.notFoundGone, textAlign: TextAlign.center),
|
||||
if (way != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () => context.go(way.location),
|
||||
child: Text(way.label),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class const TitleWithKicker({
|
|||
/// of the width.
|
||||
final Widget? trailing,
|
||||
|
||||
/// Shown on hovering the title with a mouse, as the whole of a title cut
|
||||
/// short; never on touch, where a long press means something else.
|
||||
final String? tooltip,
|
||||
|
||||
/// Keeps the kicker's line free when there is no kicker, so titles (and
|
||||
/// their [trailing] actions) side by side line up whether or not each has
|
||||
/// one, as in a grid of cards.
|
||||
|
|
@ -40,12 +44,20 @@ class const TitleWithKicker({
|
|||
);
|
||||
final kicker = this.kicker;
|
||||
final hasKicker = kicker != null && kicker.isNotEmpty;
|
||||
final titleText = Text(
|
||||
final text = Text(
|
||||
title,
|
||||
style: titleStyle,
|
||||
maxLines: titleMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
final titleText = switch (tooltip) {
|
||||
final message? => Tooltip(
|
||||
message: message,
|
||||
triggerMode: TooltipTriggerMode.manual,
|
||||
child: text,
|
||||
),
|
||||
null => text,
|
||||
};
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/l10n/app_localizations.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/widget/adaptive_sheet.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/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
|
|
@ -13,7 +14,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_info.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tagging.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/manage_tags_sheet.dart';
|
||||
|
|
@ -21,66 +21,78 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
/// Provides [child] with the studyset [studysetId]'s cards ([CardListBloc]),
|
||||
/// for [CardList] and [CardListToolbar], which can sit apart: the toolbar
|
||||
/// beside the tabs, the list in one.
|
||||
class const CardListScope({
|
||||
required final String studysetId,
|
||||
required final Widget child,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final person = PersonScope.of(context);
|
||||
return BlocProvider(
|
||||
create: (context) => CardListBloc(
|
||||
person.cards,
|
||||
person.tags,
|
||||
person.cardDeletions,
|
||||
studysetId,
|
||||
)..add(const CardListEventStarted()),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A studyset's cards: in order, draggable, each with its tags and opening
|
||||
/// its editor, all of them or those with one tag. Lives inside a scrolling
|
||||
/// screen; adding and studying are the screen's.
|
||||
/// its editor, all of them or those with one tag; from the [CardListScope]
|
||||
/// above. Lives inside a scrolling screen; adding and studying are the
|
||||
/// screen's.
|
||||
class const CardList({
|
||||
/// The person's cards.
|
||||
required final CardRepository cards,
|
||||
|
||||
/// The person's tags.
|
||||
required final TagRepository tags,
|
||||
|
||||
/// The studyset whose cards these are.
|
||||
required final String studysetId,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) => CardListBloc(
|
||||
cards,
|
||||
tags,
|
||||
PersonScope.of(context).cardDeletions,
|
||||
studysetId,
|
||||
)..add(const CardListEventStarted()),
|
||||
child: BlocBuilder<CardListBloc, CardListState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
CardListStateLoading() => const SizedBox.shrink(),
|
||||
CardListStateFailed() => Text(
|
||||
AppLocalizations.of(context).cardListFailed,
|
||||
),
|
||||
CardListStateLoaded(cards: []) => const _NoCards(),
|
||||
final CardListStateLoaded loaded => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Toolbar(state: loaded),
|
||||
if (loaded.shown.isEmpty)
|
||||
const _NothingTagged()
|
||||
else
|
||||
_Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
loaded.shown,
|
||||
loaded.tagging,
|
||||
buried: loaded.buried,
|
||||
reorderable: loaded.filter == null,
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<CardListBloc, CardListState>(
|
||||
builder: (context, state) => switch (state) {
|
||||
CardListStateLoading() => const Center(child: AppLoadingIndicator()),
|
||||
CardListStateFailed() => Text(
|
||||
AppLocalizations.of(context).cardListFailed,
|
||||
),
|
||||
CardListStateLoaded(cards: []) => const _NoCards(),
|
||||
final CardListStateLoaded loaded =>
|
||||
loaded.shown.isEmpty
|
||||
? const _NothingTagged()
|
||||
: _Cards(
|
||||
cards,
|
||||
studysetId,
|
||||
loaded.shown,
|
||||
loaded.tagging,
|
||||
buried: loaded.buried,
|
||||
reorderable: loaded.filter == null,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Above the cards: narrowing them to a tag, and managing the tags.
|
||||
class const _Toolbar({required final CardListStateLoaded state})
|
||||
extends StatelessWidget {
|
||||
/// Beside the cards: narrowing them to a tag, and managing the tags; from
|
||||
/// the [CardListScope] above, once there are cards.
|
||||
class const CardListToolbar({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<CardListBloc>().state;
|
||||
if (state is! CardListStateLoaded || state.cards.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: l10n.cardFilterTooltip,
|
||||
|
|
@ -218,19 +230,27 @@ class const _NoCards() extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.studysetFlashcardsEmpty, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.studysetFlashcardsEmptyHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
// In the middle of the tab, which the list fills.
|
||||
return Center(
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_pa
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.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_step.dart';
|
||||
|
||||
/// A studyset's learning paths in order, through every change, leaving out
|
||||
/// those whose deletion can still be undone, and which of them are
|
||||
|
|
@ -127,23 +128,33 @@ class PathListBloc(
|
|||
) async {
|
||||
final request = ++_completionRequests;
|
||||
final completed = <String>{};
|
||||
final started = <String>{};
|
||||
try {
|
||||
for (final path in _all ?? const <LearningPath>[]) {
|
||||
final steps = await _paths.stepsOf(path.id);
|
||||
final progress = await _progress.of({
|
||||
for (final step in steps) step.id: step.kind,
|
||||
});
|
||||
if (steps.isNotEmpty &&
|
||||
steps.every((step) => progress[step.id]?.done ?? false)) {
|
||||
bool done(LearningPathStep step) => progress[step.id]?.done ?? false;
|
||||
if (steps.isNotEmpty && steps.every(done)) {
|
||||
completed.add(path.id);
|
||||
}
|
||||
if (steps.any(done)) {
|
||||
started.add(path.id);
|
||||
}
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Working out completion failed', name: 'Paths', error: error);
|
||||
return;
|
||||
}
|
||||
if (request == _completionRequests) {
|
||||
emit(state.copyWith(completed: completed, completionKnown: true));
|
||||
emit(
|
||||
state.copyWith(
|
||||
completed: completed,
|
||||
started: started,
|
||||
completionKnown: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -168,6 +179,9 @@ final class const PathListState({
|
|||
/// The ids of the paths completed.
|
||||
final Set<String> completed = const {},
|
||||
|
||||
/// The ids of the paths with a step done, completed or not.
|
||||
final Set<String> started = const {},
|
||||
|
||||
/// Whether [completed] was worked out at least once.
|
||||
final bool completionKnown = false,
|
||||
|
||||
|
|
@ -184,11 +198,18 @@ final class const PathListState({
|
|||
/// Whether some path is not completed yet.
|
||||
bool get hasOpenPaths => paths.any((path) => !completed.contains(path.id));
|
||||
|
||||
/// The first path not completed yet, in order, once completion is known;
|
||||
/// null without one.
|
||||
LearningPath? get nextOpenPath => !completionKnown
|
||||
? null
|
||||
: paths.where((path) => !completed.contains(path.id)).firstOrNull;
|
||||
|
||||
/// This state with some fields replaced.
|
||||
PathListState copyWith({
|
||||
bool? loaded,
|
||||
List<LearningPath>? paths,
|
||||
Set<String>? completed,
|
||||
Set<String>? started,
|
||||
bool? completionKnown,
|
||||
String? created,
|
||||
ImportResult? importResult,
|
||||
|
|
@ -197,6 +218,7 @@ final class const PathListState({
|
|||
loaded: loaded ?? this.loaded,
|
||||
paths: paths ?? this.paths,
|
||||
completed: completed ?? this.completed,
|
||||
started: started ?? this.started,
|
||||
completionKnown: completionKnown ?? this.completionKnown,
|
||||
created: created ?? this.created,
|
||||
importResult: importResult ?? this.importResult,
|
||||
|
|
@ -208,6 +230,7 @@ final class const PathListState({
|
|||
loaded,
|
||||
paths,
|
||||
completed,
|
||||
started,
|
||||
completionKnown,
|
||||
created,
|
||||
importResult,
|
||||
|
|
|
|||
|
|
@ -14,9 +14,13 @@ import 'package:material_ui/material_ui.dart';
|
|||
|
||||
/// The studyset [studysetId]'s learning paths, from the [PathListBloc]
|
||||
/// above: each opens to be worked through; completed ones are dimmed; with
|
||||
/// none, it says so.
|
||||
class const PathList({required final String studysetId, super.key})
|
||||
extends StatelessWidget {
|
||||
/// none, it says so in the middle of the tab. While [reordering], each has
|
||||
/// a handle to drag it elsewhere instead of its options.
|
||||
class const PathList({
|
||||
required final String studysetId,
|
||||
final bool reordering = false,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<PathListBloc, PathListState>(
|
||||
|
|
@ -27,21 +31,58 @@ class const PathList({required final String studysetId, super.key})
|
|||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.paths.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.pathsEmpty, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.pathsEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (reordering) {
|
||||
return ReorderableListView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: (from, to) => context.read<PathListBloc>().add(
|
||||
PathListEventMoveRequested(state.paths[from].id, to),
|
||||
),
|
||||
children: [
|
||||
for (final (index, path) in state.paths.indexed)
|
||||
ListTile(
|
||||
key: ValueKey(path.id),
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(
|
||||
path.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: IconButton(
|
||||
tooltip: l10n.homeDragToReorder,
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
LucideIcons.gripHorizontal,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
|
|
|
|||
|
|
@ -32,20 +32,24 @@ class const NoteList({required final String studysetId, super.key})
|
|||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.notes.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.notesEmpty, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.notesEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
// In the middle of the tab, which the list fills.
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,20 +36,30 @@ class const PdfList({required final String studysetId, super.key})
|
|||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.rows.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(l10n.filesEmpty, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l10n.filesEmptyHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
// In the middle of the tab, which the list fills.
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,19 @@ class const LicenseFooter({
|
|||
required final StudysetContent content,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// Whether the footer shows for [content]: with a licence, a holder or a
|
||||
/// year.
|
||||
static bool shows(StudysetContent content) =>
|
||||
(content.license?.trim() ?? '').isNotEmpty ||
|
||||
(content.licenseHolder?.trim() ?? '').isNotEmpty ||
|
||||
content.licenseYear != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final license = content.license?.trim() ?? '';
|
||||
final holder = content.licenseHolder?.trim() ?? '';
|
||||
final year = content.licenseYear;
|
||||
if (license.isEmpty && holder.isEmpty && year == null) {
|
||||
if (!shows(content)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -31,27 +38,35 @@ class const LicenseFooter({
|
|||
if (holder.isNotEmpty) holder,
|
||||
].join(' ');
|
||||
final url = license.isEmpty ? null : licenseUrl(license);
|
||||
// Two lines at most.
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (credit.isNotEmpty) Text('© $credit', style: muted),
|
||||
if (credit.isNotEmpty && license.isNotEmpty)
|
||||
Text(' · ', style: muted),
|
||||
if (license.isNotEmpty)
|
||||
if (url == null)
|
||||
Text(license, style: muted)
|
||||
else
|
||||
InkWell(
|
||||
onTap: () => launchUrl(url),
|
||||
child: Text(
|
||||
'$license ↗',
|
||||
style: muted?.copyWith(color: theme.colorScheme.primary),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (credit.isNotEmpty) TextSpan(text: '© $credit'),
|
||||
if (credit.isNotEmpty && license.isNotEmpty)
|
||||
const TextSpan(text: ' · '),
|
||||
if (license.isNotEmpty)
|
||||
if (url == null)
|
||||
TextSpan(text: license)
|
||||
else
|
||||
WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: InkWell(
|
||||
onTap: () => launchUrl(url),
|
||||
child: Text(
|
||||
'$license ↗',
|
||||
style: muted?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
style: muted,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/confirm_dialog.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/studyset_study_options_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -99,3 +101,28 @@ Future<void> showBibliography(BuildContext context, Studyset studyset) {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Asks, then starts [studyset]'s progress afresh, with everything inside
|
||||
/// it, and offers to undo that for a moment.
|
||||
Future<void> resetStudysetProgress(
|
||||
BuildContext context,
|
||||
Studyset studyset,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
if (!await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.studysetResetTitle,
|
||||
message: l10n.studysetResetMessage,
|
||||
confirmLabel: l10n.studysetResetConfirm,
|
||||
) ||
|
||||
!context.mounted) {
|
||||
return;
|
||||
}
|
||||
bloc.add(StudysetListEventResetRequested(studyset.id));
|
||||
showUndoSnackBar(
|
||||
context,
|
||||
message: l10n.studysetResetDone,
|
||||
onUndo: () => bloc.add(const StudysetListEventResetUndone()),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ 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:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Where a studyset sits, on one line: "Library › Japanese › Kana ›
|
||||
/// Hiragana", the studyset itself last, in the primary colour.
|
||||
/// 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.
|
||||
|
|
@ -22,14 +22,19 @@ class const StudysetBreadcrumb({
|
|||
required final Studyset current,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// The room a separator takes: the chevron and the space around it.
|
||||
static const _separatorWidth = 24.0;
|
||||
/// The room around a separator, on each side.
|
||||
static const _separatorPadding = 6.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final style = theme.textTheme.bodyMedium;
|
||||
final base = theme.textTheme.bodyMedium ?? const TextStyle();
|
||||
final muted = base.copyWith(color: theme.colorScheme.onSurfaceVariant);
|
||||
final active = base.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final steps = <_Step>[
|
||||
_Step(l10n.libraryTitle, () => context.go(RoutePaths.library)),
|
||||
for (final ancestor in ancestors)
|
||||
|
|
@ -38,12 +43,16 @@ class const StudysetBreadcrumb({
|
|||
() => context.go(RoutePaths.studyset(ancestor.id)),
|
||||
),
|
||||
];
|
||||
Widget separator() => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _separatorPadding),
|
||||
child: Text('›', style: muted),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final scale = MediaQuery.textScalerOf(context);
|
||||
double widthOf(String label) {
|
||||
double widthOf(String label, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: label, style: style),
|
||||
maxLines: 1,
|
||||
|
|
@ -52,69 +61,54 @@ class const StudysetBreadcrumb({
|
|||
)..layout();
|
||||
final width = painter.width;
|
||||
painter.dispose();
|
||||
return width + 8;
|
||||
return width;
|
||||
}
|
||||
|
||||
final widths = [for (final step in steps) widthOf(step.label)];
|
||||
final currentWidth = widthOf(current.content.title);
|
||||
final foldWidth = widthOf('…') + 16;
|
||||
final separatorWidth = widthOf('›', muted) + 2 * _separatorPadding;
|
||||
final widths = [for (final step in steps) widthOf(step.label, muted)];
|
||||
final currentWidth = widthOf(current.content.title, active);
|
||||
final foldWidth = widthOf('…', muted);
|
||||
// Fold the first steps away until the rest fits; at the least, the
|
||||
// fold and the studyset itself remain.
|
||||
var folded = 0;
|
||||
double widthFrom(int first) =>
|
||||
(first > 0 ? foldWidth + _separatorWidth : 0) +
|
||||
(first > 0 ? foldWidth + separatorWidth : 0) +
|
||||
widths.skip(first).fold(0.0, (sum, w) => sum + w) +
|
||||
(steps.length - first) * _separatorWidth +
|
||||
(steps.length - first) * separatorWidth +
|
||||
currentWidth;
|
||||
while (folded < steps.length &&
|
||||
widthFrom(folded) > constraints.maxWidth) {
|
||||
folded++;
|
||||
}
|
||||
final separator = Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return Row(
|
||||
children: [
|
||||
if (folded > 0) ...[
|
||||
_Fold(steps: steps.take(folded).toList().reversed.toList()),
|
||||
SizedBox(
|
||||
width: _separatorWidth,
|
||||
child: Center(child: separator),
|
||||
_Fold(
|
||||
steps: steps.take(folded).toList().reversed.toList(),
|
||||
style: muted,
|
||||
hover: muted.copyWith(color: active.color),
|
||||
),
|
||||
separator(),
|
||||
],
|
||||
for (final step in steps.skip(
|
||||
math.min(folded, steps.length),
|
||||
)) ...[
|
||||
InkWell(
|
||||
onTap: step.onTap,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
step.label,
|
||||
maxLines: 1,
|
||||
style: style?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: step.onTap,
|
||||
child: Text(step.label, maxLines: 1, style: muted),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: _separatorWidth,
|
||||
child: Center(child: separator),
|
||||
),
|
||||
separator(),
|
||||
],
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
current.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style?.copyWith(color: theme.colorScheme.primary),
|
||||
),
|
||||
child: Text(
|
||||
current.content.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: active,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -128,27 +122,35 @@ class const StudysetBreadcrumb({
|
|||
/// A step of the way, and where it leads.
|
||||
class const _Step(final String label, final VoidCallback onTap);
|
||||
|
||||
/// The steps that didn't fit, behind a "…", nearest first.
|
||||
class const _Fold({required final List<_Step> steps}) extends StatelessWidget {
|
||||
/// The steps that didn't fit, behind a "…" that turns primary under the
|
||||
/// pointer, nearest first in its menu.
|
||||
class const _Fold({
|
||||
required final List<_Step> steps,
|
||||
required final TextStyle style,
|
||||
required final TextStyle hover,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return PopupMenuButton<_Step>(
|
||||
tooltip: '',
|
||||
onSelected: (step) => step.onTap(),
|
||||
itemBuilder: (context) => [
|
||||
for (final step in steps)
|
||||
PopupMenuItem(value: step, child: Text(step.label)),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
child: Text(
|
||||
'…',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
State<_Fold> createState() => _FoldState();
|
||||
}
|
||||
|
||||
class _FoldState() extends State<_Fold> {
|
||||
var _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MenuAnchor(
|
||||
menuChildren: [
|
||||
for (final step in widget.steps)
|
||||
MenuItemButton(onPressed: step.onTap, child: Text(step.label)),
|
||||
],
|
||||
builder: (context, menu, _) => MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
child: Text('…', style: _hovered ? widget.hover : widget.style),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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/content_counts_line.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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
|
||||
|
|
@ -11,6 +10,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bl
|
|||
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_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -19,9 +19,10 @@ import 'package:material_ui/material_ui.dart';
|
|||
/// scoped to [parent].
|
||||
///
|
||||
/// On top, while there are any, "All topics": the whole studyset with
|
||||
/// everything inside it, and the study it invites. 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.
|
||||
/// 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.
|
||||
/// Without topics, an invitation to create the first.
|
||||
class const StudysetRail({
|
||||
/// The studyset whose topics these are.
|
||||
|
|
@ -29,6 +30,9 @@ class const StudysetRail({
|
|||
|
||||
/// All the person's studysets, for the colours topics take.
|
||||
required final List<Studyset> all,
|
||||
|
||||
/// Opens Add material, for "All topics" while nothing is due.
|
||||
final VoidCallback? onAddMaterial,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -37,6 +41,13 @@ class const StudysetRail({
|
|||
|
||||
class _StudysetRailState() extends State<StudysetRail> {
|
||||
var _reordering = false;
|
||||
final _scroll = ScrollController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
|
|
@ -63,17 +74,23 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Opens [studyset]'s screen, closing the drawer it may be in.
|
||||
void _open(Studyset studyset) {
|
||||
/// 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.
|
||||
void _open(Studyset studyset) {
|
||||
_closeDrawer();
|
||||
context.push(RoutePaths.studyset(studyset.id));
|
||||
}
|
||||
|
||||
Future<void> _study(Studyset studyset) async {
|
||||
final bloc = context.read<StudysetListBloc>();
|
||||
_closeDrawer();
|
||||
await startStudying(context, studyset.id);
|
||||
bloc.add(const StudysetListEventProgressRefreshRequested());
|
||||
}
|
||||
|
|
@ -92,15 +109,22 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
StudysetListStateLoaded(:final progress) => progress,
|
||||
_ => const <String, StudyProgress>{},
|
||||
};
|
||||
final addMaterial = widget.onAddMaterial;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The whole studyset, above the topics it adds up.
|
||||
if (topics.isNotEmpty && !_reordering)
|
||||
if (topics.isNotEmpty)
|
||||
_AllTopics(
|
||||
parent: widget.parent,
|
||||
progress: progress[widget.parent.id],
|
||||
onStudy: () => _study(widget.parent),
|
||||
onAdd: addMaterial == null
|
||||
? null
|
||||
: () {
|
||||
_closeDrawer();
|
||||
addMaterial();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
dense: true,
|
||||
|
|
@ -145,8 +169,12 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
StudysetListStateLoaded(studysets: []) => _NoTopics(
|
||||
onCreate: _create,
|
||||
),
|
||||
StudysetListStateLoaded() when _reordering =>
|
||||
ReorderableListView(
|
||||
StudysetListStateLoaded() when _reordering => Scrollbar(
|
||||
controller: _scroll,
|
||||
thumbVisibility: true,
|
||||
child: ReorderableListView(
|
||||
scrollController: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorderItem: (from, to) =>
|
||||
context.read<StudysetListBloc>().add(
|
||||
|
|
@ -167,42 +195,71 @@ class _StudysetRailState() extends State<StudysetRail> {
|
|||
title: topic.content.title,
|
||||
kicker: topic.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
tooltip: topic.content.title,
|
||||
),
|
||||
trailing: ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: IconButton(
|
||||
tooltip: l10n.studysetDragToReorder,
|
||||
icon: const Icon(LucideIcons.gripVertical),
|
||||
icon: Icon(
|
||||
LucideIcons.gripHorizontal,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StudysetListStateLoaded() => ListView(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
),
|
||||
StudysetListStateLoaded() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final topic in topics)
|
||||
_Topic(
|
||||
key: ValueKey(topic.id),
|
||||
topic: topic,
|
||||
progress: progress[topic.id],
|
||||
onOpen: () => _open(topic),
|
||||
onStudy: () => _study(topic),
|
||||
onEdit: () => _edit(topic),
|
||||
onDelete: () => deleteStudysetWithUndo(
|
||||
this.context,
|
||||
topic,
|
||||
Expanded(
|
||||
child: Scrollbar(
|
||||
controller: _scroll,
|
||||
thumbVisibility: true,
|
||||
child: ListView(
|
||||
controller: _scroll,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
children: [
|
||||
for (final topic in topics)
|
||||
_Topic(
|
||||
key: ValueKey(topic.id),
|
||||
topic: topic,
|
||||
progress: progress[topic.id],
|
||||
onOpen: () => _open(topic),
|
||||
onStudy: () => _study(topic),
|
||||
onEdit: () => _edit(topic),
|
||||
onReset: () =>
|
||||
resetStudysetProgress(this.context, topic),
|
||||
onDelete: () => deleteStudysetWithUndo(
|
||||
this.context,
|
||||
topic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: TextButton.icon(
|
||||
onPressed: _create,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.studysetNewSubStudyset),
|
||||
),
|
||||
// At the foot, below a line, however long the list.
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _create,
|
||||
icon: const Icon(LucideIcons.plus),
|
||||
label: Text(l10n.studysetNewSubStudyset),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -239,148 +296,217 @@ class const _Count(final int count) extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The whole studyset with everything inside it: what it holds, and the
|
||||
/// study it invites.
|
||||
class const _AllTopics({
|
||||
required final Studyset parent,
|
||||
/// What a studyset holds, as its topics' lines say it: "2 paths · 14
|
||||
/// flashcards · 1 file", "Empty" with nothing; a line kept free while it is
|
||||
/// worked out, so the row doesn't move once it is.
|
||||
class const _Holds({
|
||||
required final StudyProgress? progress,
|
||||
required final VoidCallback onStudy,
|
||||
final TextStyle? style,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final due = progress?.due ?? 0;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
leading: StudyActionIcon(
|
||||
progress: progress,
|
||||
archived: parent.isArchived,
|
||||
onStudy: onStudy,
|
||||
onAdd: null,
|
||||
final progress = this.progress;
|
||||
if (progress == null) {
|
||||
return Text('\u00A0', style: style);
|
||||
}
|
||||
final parts = [
|
||||
if (progress.paths > 0) l10n.studysetCountPaths(progress.paths),
|
||||
if (progress.cards > 0) l10n.studysetCountFlashcards(progress.cards),
|
||||
if (progress.notes > 0) l10n.studysetCountNotes(progress.notes),
|
||||
if (progress.files > 0) l10n.studysetCountFiles(progress.files),
|
||||
];
|
||||
return Text(
|
||||
parts.isEmpty ? l10n.studysetEmpty : parts.join(' · '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole studyset with everything inside it, on the primary colour:
|
||||
/// the study it invites as a round icon, the only part that reacts, and what
|
||||
/// it holds.
|
||||
class const _AllTopics({
|
||||
required final Studyset parent,
|
||||
required final StudyProgress? progress,
|
||||
required final VoidCallback onStudy,
|
||||
required final VoidCallback? onAdd,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.primary.withValues(alpha: 0.4)),
|
||||
),
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
StudyActionIcon(
|
||||
progress: progress,
|
||||
archived: parent.isArchived,
|
||||
onStudy: onStudy,
|
||||
onAdd: onAdd,
|
||||
background: scheme.primary.withValues(alpha: 0.15),
|
||||
foreground: scheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.studysetAllTopics,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: scheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (progress != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
_Holds(
|
||||
progress: progress,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onPrimaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text(l10n.studysetAllTopics),
|
||||
subtitle: ContentCountsLine(
|
||||
counts: [
|
||||
(count: progress?.paths, label: l10n.studysetCountPaths),
|
||||
(count: progress?.cards, label: l10n.studysetCountFlashcards),
|
||||
(count: progress?.notes, label: l10n.studysetCountNotes),
|
||||
],
|
||||
placeholder: progress == null ? '' : l10n.studysetNoContent,
|
||||
),
|
||||
onTap: due > 0 && !parent.isArchived ? onStudy : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A topic: the study it invites, its title, what it holds, and its
|
||||
/// options.
|
||||
/// 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 VoidCallback onOpen,
|
||||
required final VoidCallback onStudy,
|
||||
required final VoidCallback onEdit,
|
||||
required final VoidCallback onReset,
|
||||
required final VoidCallback onDelete,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_Topic> createState() => _TopicState();
|
||||
}
|
||||
|
||||
class _TopicState() extends State<_Topic> {
|
||||
final _options = MenuController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final progress = this.progress;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: Material(
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
final theme = Theme.of(context);
|
||||
final scheme = theme.colorScheme;
|
||||
final error = scheme.error;
|
||||
final topic = widget.topic;
|
||||
return MenuAnchor(
|
||||
controller: _options,
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.pencil),
|
||||
onPressed: widget.onEdit,
|
||||
child: Text(l10n.studysetMenuEdit),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.rotateCcw),
|
||||
onPressed: widget.onReset,
|
||||
child: Text(l10n.studysetMenuReset),
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(LucideIcons.trash2, color: error),
|
||||
onPressed: widget.onDelete,
|
||||
child: Text(l10n.studysetMenuDelete, style: TextStyle(color: error)),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: GestureDetector(
|
||||
onLongPressStart: (details) =>
|
||||
menu.open(position: details.localPosition),
|
||||
child: Material(
|
||||
color: scheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: widget.onOpen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
StudyActionIcon(
|
||||
progress: progress,
|
||||
archived: topic.isArchived,
|
||||
onStudy: onStudy,
|
||||
onAdd: onOpen,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TitleWithKicker(
|
||||
title: topic.content.title,
|
||||
kicker: topic.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
),
|
||||
if (progress != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
ContentCountsLine(
|
||||
counts: [
|
||||
(
|
||||
count: progress.paths,
|
||||
label: l10n.studysetCountPaths,
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: scheme.outlineVariant),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 4, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
StudyActionIcon(
|
||||
progress: widget.progress,
|
||||
archived: topic.isArchived,
|
||||
onStudy: widget.onStudy,
|
||||
onAdd: widget.onOpen,
|
||||
background: scheme.surfaceContainerHigh,
|
||||
foreground: scheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TitleWithKicker(
|
||||
title: topic.content.title,
|
||||
kicker: topic.content.secondaryTitle,
|
||||
titleMaxLines: 1,
|
||||
tooltip: topic.content.title,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
_Holds(
|
||||
progress: widget.progress,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: scheme.onSurfaceVariant,
|
||||
),
|
||||
(
|
||||
count: progress.cards,
|
||||
label: l10n.studysetCountFlashcards,
|
||||
),
|
||||
(
|
||||
count: progress.notes,
|
||||
label: l10n.studysetCountNotes,
|
||||
),
|
||||
],
|
||||
placeholder: l10n.studysetNoContent,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
onSelected: (option) => option(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: onEdit,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.pencil),
|
||||
title: Text(l10n.studysetMenuEdit),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: onDelete,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
title: Text(
|
||||
l10n.studysetMenuDelete,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
// The menu opens below it.
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
tooltip: l10n.studysetOptions,
|
||||
onPressed: () {
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
menu.isOpen
|
||||
? menu.close()
|
||||
: menu.open(
|
||||
position: box == null
|
||||
? null
|
||||
: Offset(box.size.width - 48, 64),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -391,20 +517,22 @@ class const _Topic({
|
|||
}
|
||||
}
|
||||
|
||||
/// The study a studyset invites, as a round icon: a review when reviews are
|
||||
/// due, studying when only new cards are, adding content otherwise (where
|
||||
/// [onAdd] is given), and a pause while it rests in the archive.
|
||||
/// 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
|
||||
/// while it rests in the archive.
|
||||
class const StudyActionIcon({
|
||||
required final StudyProgress? progress,
|
||||
required final bool archived,
|
||||
required final VoidCallback onStudy,
|
||||
required final VoidCallback? onAdd,
|
||||
required final Color background,
|
||||
required final Color foreground,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (icon, tooltip, onTap) = switch (progress) {
|
||||
_ when archived => (
|
||||
LucideIcons.circlePause,
|
||||
|
|
@ -426,14 +554,14 @@ class const StudyActionIcon({
|
|||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Material(
|
||||
color: scheme.secondaryContainer,
|
||||
color: background,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(icon, size: 20, color: scheme.onSecondaryContainer),
|
||||
child: SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Icon(icon, size: 18, color: foreground),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -115,7 +115,7 @@ void main() {
|
|||
await showApp(tester);
|
||||
await openJapanese(tester);
|
||||
|
||||
await tester.tap(find.text('Learn 1'));
|
||||
await tester.tap(find.text('Learn 1 flashcard'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Show answer'), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue