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:
Tizian 「ティツィアーン」 2026-10-08 11:42:50 +02:00
commit 72b29c7e80
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
23 changed files with 1949 additions and 616 deletions

View file

@ -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.",

View file

@ -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.",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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),
);

View file

@ -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);

View file

@ -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),
),
],
],
),
),
),
);

View file

@ -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,

View file

@ -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,
),
),
],
),
),
);
}

View file

@ -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,

View file

@ -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(

View file

@ -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,
),
),
),
],
],
),
),
);
}

View file

@ -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,
),
),
],
),
),
);
}

View file

@ -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,
),
);
}

View file

@ -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()),
);
}

View file

@ -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),
),
),
);
}

View file

@ -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),
),
),
),

View file

@ -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