feat(studyset): Material, Progress and Study plan in place of the tabs
The studyset's four tabs become three parts, as Vaia has them, chosen with a switch in the header that ticks as the navigation bar does: below expanded windows in a line of its own under the title, above the path and Topics; from there on at the end of the path's line, which the topics leave free there. - Material: the flashcards, notes and files one below the other, the first three of each with Show all; Browse, the tag filter (where all cards show, or while they are narrowed) and Reorder in each section's header; an outlined box where a section is empty. - Progress, once anything in the studyset was studied (a card rated, a path finished): how much is learnt (mature cards), how many studied cards are due today, and how long it was studied this week on this device, with a line of the seven days (StudysetActivityCubit, from the reviews' durations). - Study plan: a ring of the paths finished, Next up (study the cards due, go on with the first path not finished), then the paths. It opens on the part it was left on; a tab kept before counts as its part. Opened for the first time, it opens on the study plan while a path is unfinished, else on the material. Material added lands in its part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1df60c8830
commit
246c9dc143
19 changed files with 1992 additions and 482 deletions
|
|
@ -1645,5 +1645,30 @@
|
|||
"searchEngineCustom": "Eigene Adresse",
|
||||
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||
"searchEngineCustomInvalid": "Eine http- oder https-Adresse mit %s, wo die Frage hingehört.",
|
||||
"searchEngineCustomSave": "Verwenden"
|
||||
"searchEngineCustomSave": "Verwenden",
|
||||
|
||||
"studysetPartMaterial": "Material",
|
||||
"studysetPartProgress": "Fortschritt",
|
||||
"studysetPartPlan": "Lernplan",
|
||||
"studysetShowAll": "Alle zeigen",
|
||||
"studysetShowFewer": "Weniger zeigen",
|
||||
"progressLearned": "Gelernt",
|
||||
"progressLearnedInfo": "Karten, die du drei Wochen oder länger behältst.",
|
||||
"progressOfStudyset": "dieses Lernsets",
|
||||
"progressCardsLearned": "{count, plural, =1{{learned} von 1 Karte} other{{learned} von {count} Karten}}",
|
||||
"progressToReview": "Zu wiederholen",
|
||||
"progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.",
|
||||
"progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}",
|
||||
"progressStudyTime": "Lernzeit",
|
||||
"progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.",
|
||||
"progressThisWeek": "diese Woche",
|
||||
"progressMinutes": "{minutes} Min.",
|
||||
"progressHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"planPathsDone": "{count, plural, =1{{done} von 1 Pfad abgeschlossen} other{{done} von {count} Pfaden abgeschlossen}}",
|
||||
"planNoPaths": "Noch keine Lernpfade",
|
||||
"planNextUp": "Als Nächstes",
|
||||
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
||||
"planContinue": "„{title}“ fortsetzen",
|
||||
"planNothing": "Gerade steht nichts an.",
|
||||
"planLearningPaths": "Lernpfade"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1927,5 +1927,37 @@
|
|||
"searchEngineCustom": "Your own address",
|
||||
"searchEngineCustomHint": "https://example.org/search?q=%s",
|
||||
"searchEngineCustomInvalid": "An http or https address with %s where the question goes.",
|
||||
"searchEngineCustomSave": "Use"
|
||||
"searchEngineCustomSave": "Use",
|
||||
|
||||
"studysetPartMaterial": "Material",
|
||||
"studysetPartProgress": "Progress",
|
||||
"studysetPartPlan": "Study plan",
|
||||
"studysetShowAll": "Show all",
|
||||
"studysetShowFewer": "Show fewer",
|
||||
"progressLearned": "Learned",
|
||||
"progressLearnedInfo": "Cards you will remember for three weeks or more.",
|
||||
"progressOfStudyset": "of this studyset",
|
||||
"progressCardsLearned": "{count, plural, =1{{learned} of 1 card} other{{learned} of {count} cards}}",
|
||||
"@progressCardsLearned": {"placeholders": {"learned": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"progressToReview": "To review",
|
||||
"progressToReviewInfo": "Cards studied before that are due again today.",
|
||||
"progressDueToday": "{count, plural, =1{card due today} other{cards due today}}",
|
||||
"@progressDueToday": {"placeholders": {"count": {"type": "int"}}},
|
||||
"progressStudyTime": "Study time",
|
||||
"progressStudyTimeInfo": "How long you studied this studyset's flashcards on this device in the last seven days.",
|
||||
"progressThisWeek": "this week",
|
||||
"progressMinutes": "{minutes} min",
|
||||
"@progressMinutes": {"placeholders": {"minutes": {"type": "int"}}},
|
||||
"progressHoursMinutes": "{hours} h {minutes} min",
|
||||
"@progressHoursMinutes": {"placeholders": {"hours": {"type": "int"}, "minutes": {"type": "int"}}},
|
||||
"planPathsDone": "{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}",
|
||||
"@planPathsDone": {"placeholders": {"done": {"type": "int"}, "count": {"type": "int"}}},
|
||||
"planNoPaths": "No learning paths yet",
|
||||
"planNextUp": "Next up",
|
||||
"planReview": "{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}",
|
||||
"@planReview": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planContinue": "Continue “{title}”",
|
||||
"@planContinue": {"placeholders": {"title": {"type": "String"}}},
|
||||
"planNothing": "Nothing due right now.",
|
||||
"planLearningPaths": "Learning paths"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9752,6 +9752,150 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Use'**
|
||||
String get searchEngineCustomSave;
|
||||
|
||||
/// No description provided for @studysetPartMaterial.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Material'**
|
||||
String get studysetPartMaterial;
|
||||
|
||||
/// No description provided for @studysetPartProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Progress'**
|
||||
String get studysetPartProgress;
|
||||
|
||||
/// No description provided for @studysetPartPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study plan'**
|
||||
String get studysetPartPlan;
|
||||
|
||||
/// No description provided for @studysetShowAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show all'**
|
||||
String get studysetShowAll;
|
||||
|
||||
/// No description provided for @studysetShowFewer.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show fewer'**
|
||||
String get studysetShowFewer;
|
||||
|
||||
/// No description provided for @progressLearned.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learned'**
|
||||
String get progressLearned;
|
||||
|
||||
/// No description provided for @progressLearnedInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards you will remember for three weeks or more.'**
|
||||
String get progressLearnedInfo;
|
||||
|
||||
/// No description provided for @progressOfStudyset.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'of this studyset'**
|
||||
String get progressOfStudyset;
|
||||
|
||||
/// No description provided for @progressCardsLearned.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{{learned} of 1 card} other{{learned} of {count} cards}}'**
|
||||
String progressCardsLearned(int learned, int count);
|
||||
|
||||
/// No description provided for @progressToReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'To review'**
|
||||
String get progressToReview;
|
||||
|
||||
/// No description provided for @progressToReviewInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cards studied before that are due again today.'**
|
||||
String get progressToReviewInfo;
|
||||
|
||||
/// No description provided for @progressDueToday.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{card due today} other{cards due today}}'**
|
||||
String progressDueToday(int count);
|
||||
|
||||
/// No description provided for @progressStudyTime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Study time'**
|
||||
String get progressStudyTime;
|
||||
|
||||
/// No description provided for @progressStudyTimeInfo.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How long you studied this studyset\'s flashcards on this device in the last seven days.'**
|
||||
String get progressStudyTimeInfo;
|
||||
|
||||
/// No description provided for @progressThisWeek.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'this week'**
|
||||
String get progressThisWeek;
|
||||
|
||||
/// No description provided for @progressMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{minutes} min'**
|
||||
String progressMinutes(int minutes);
|
||||
|
||||
/// No description provided for @progressHoursMinutes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{hours} h {minutes} min'**
|
||||
String progressHoursMinutes(int hours, int minutes);
|
||||
|
||||
/// No description provided for @planPathsDone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{{done} of 1 path finished} other{{done} of {count} paths finished}}'**
|
||||
String planPathsDone(int done, int count);
|
||||
|
||||
/// No description provided for @planNoPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No learning paths yet'**
|
||||
String get planNoPaths;
|
||||
|
||||
/// No description provided for @planNextUp.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Next up'**
|
||||
String get planNextUp;
|
||||
|
||||
/// No description provided for @planReview.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Study 1 flashcard} other{Study {count} flashcards}}'**
|
||||
String planReview(int count);
|
||||
|
||||
/// No description provided for @planContinue.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Continue “{title}”'**
|
||||
String planContinue(String title);
|
||||
|
||||
/// No description provided for @planNothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nothing due right now.'**
|
||||
String get planNothing;
|
||||
|
||||
/// No description provided for @planLearningPaths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Learning paths'**
|
||||
String get planLearningPaths;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -6126,4 +6126,117 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchEngineCustomSave => 'Verwenden';
|
||||
|
||||
@override
|
||||
String get studysetPartMaterial => 'Material';
|
||||
|
||||
@override
|
||||
String get studysetPartProgress => 'Fortschritt';
|
||||
|
||||
@override
|
||||
String get studysetPartPlan => 'Lernplan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Alle zeigen';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Weniger zeigen';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Gelernt';
|
||||
|
||||
@override
|
||||
String get progressLearnedInfo =>
|
||||
'Karten, die du drei Wochen oder länger behältst.';
|
||||
|
||||
@override
|
||||
String get progressOfStudyset => 'dieses Lernsets';
|
||||
|
||||
@override
|
||||
String progressCardsLearned(int learned, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$learned von $count Karten',
|
||||
one: '$learned von 1 Karte',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressToReview => 'Zu wiederholen';
|
||||
|
||||
@override
|
||||
String get progressToReviewInfo =>
|
||||
'Schon gelernte Karten, die heute wieder fällig sind.';
|
||||
|
||||
@override
|
||||
String progressDueToday(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Karten heute fällig',
|
||||
one: 'Karte heute fällig',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressStudyTime => 'Lernzeit';
|
||||
|
||||
@override
|
||||
String get progressStudyTimeInfo =>
|
||||
'Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen auf diesem Gerät gelernt hast.';
|
||||
|
||||
@override
|
||||
String get progressThisWeek => 'diese Woche';
|
||||
|
||||
@override
|
||||
String progressMinutes(int minutes) {
|
||||
return '$minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String progressHoursMinutes(int hours, int minutes) {
|
||||
return '$hours Std. $minutes Min.';
|
||||
}
|
||||
|
||||
@override
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$done von $count Pfaden abgeschlossen',
|
||||
one: '$done von 1 Pfad abgeschlossen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNoPaths => 'Noch keine Lernpfade';
|
||||
|
||||
@override
|
||||
String get planNextUp => 'Als Nächstes';
|
||||
|
||||
@override
|
||||
String planReview(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Karteikarten lernen',
|
||||
one: '1 Karteikarte lernen',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planContinue(String title) {
|
||||
return '„$title“ fortsetzen';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNothing => 'Gerade steht nichts an.';
|
||||
|
||||
@override
|
||||
String get planLearningPaths => 'Lernpfade';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6071,6 +6071,119 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get searchEngineCustomSave => 'Use';
|
||||
|
||||
@override
|
||||
String get studysetPartMaterial => 'Material';
|
||||
|
||||
@override
|
||||
String get studysetPartProgress => 'Progress';
|
||||
|
||||
@override
|
||||
String get studysetPartPlan => 'Study plan';
|
||||
|
||||
@override
|
||||
String get studysetShowAll => 'Show all';
|
||||
|
||||
@override
|
||||
String get studysetShowFewer => 'Show fewer';
|
||||
|
||||
@override
|
||||
String get progressLearned => 'Learned';
|
||||
|
||||
@override
|
||||
String get progressLearnedInfo =>
|
||||
'Cards you will remember for three weeks or more.';
|
||||
|
||||
@override
|
||||
String get progressOfStudyset => 'of this studyset';
|
||||
|
||||
@override
|
||||
String progressCardsLearned(int learned, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$learned of $count cards',
|
||||
one: '$learned of 1 card',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressToReview => 'To review';
|
||||
|
||||
@override
|
||||
String get progressToReviewInfo =>
|
||||
'Cards studied before that are due again today.';
|
||||
|
||||
@override
|
||||
String progressDueToday(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'cards due today',
|
||||
one: 'card due today',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get progressStudyTime => 'Study time';
|
||||
|
||||
@override
|
||||
String get progressStudyTimeInfo =>
|
||||
'How long you studied this studyset\'s flashcards on this device in the last seven days.';
|
||||
|
||||
@override
|
||||
String get progressThisWeek => 'this week';
|
||||
|
||||
@override
|
||||
String progressMinutes(int minutes) {
|
||||
return '$minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String progressHoursMinutes(int hours, int minutes) {
|
||||
return '$hours h $minutes min';
|
||||
}
|
||||
|
||||
@override
|
||||
String planPathsDone(int done, int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$done of $count paths finished',
|
||||
one: '$done of 1 path finished',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNoPaths => 'No learning paths yet';
|
||||
|
||||
@override
|
||||
String get planNextUp => 'Next up';
|
||||
|
||||
@override
|
||||
String planReview(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Study $count flashcards',
|
||||
one: 'Study 1 flashcard',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String planContinue(String title) {
|
||||
return 'Continue “$title”';
|
||||
}
|
||||
|
||||
@override
|
||||
String get planNothing => 'Nothing due right now.';
|
||||
|
||||
@override
|
||||
String get planLearningPaths => 'Learning paths';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ class const SliverCardList({
|
|||
|
||||
/// The space around the cards in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many cards show at most, the first ones; all for null.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -97,7 +100,11 @@ class const SliverCardList({
|
|||
: _SliverCards(
|
||||
cards,
|
||||
studysetId,
|
||||
loaded.shown,
|
||||
switch (limit) {
|
||||
final limit? when loaded.shown.length > limit =>
|
||||
loaded.shown.sublist(0, limit),
|
||||
_ => loaded.shown,
|
||||
},
|
||||
loaded.tagging,
|
||||
buried: loaded.buried,
|
||||
padding: padding,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -31,6 +32,10 @@ class const SliverNoteList({
|
|||
|
||||
/// The space around the notes in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many notes show at most, the first ones; all for null. Reordering
|
||||
/// shows them all.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -89,7 +94,7 @@ class const SliverNoteList({
|
|||
return SliverPadding(
|
||||
padding: padding,
|
||||
sliver: SliverList.separated(
|
||||
itemCount: notes.length,
|
||||
itemCount: math.min(notes.length, limit ?? notes.length),
|
||||
itemBuilder: (context, index) => _NoteTile(
|
||||
key: ValueKey(notes[index].id),
|
||||
studysetId: studysetId,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -40,6 +41,10 @@ class const SliverPdfList({
|
|||
|
||||
/// The space around the files in the scrolling view.
|
||||
final EdgeInsets padding = EdgeInsets.zero,
|
||||
|
||||
/// How many files show at most, the first ones; all for null. Reordering
|
||||
/// shows them all.
|
||||
final int? limit,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
|
|
@ -107,7 +112,7 @@ class const SliverPdfList({
|
|||
),
|
||||
),
|
||||
SliverList.separated(
|
||||
itemCount: rows.length,
|
||||
itemCount: math.min(rows.length, limit ?? rows.length),
|
||||
itemBuilder: (context, index) => _PdfTile(
|
||||
key: ValueKey(rows[index].source.id),
|
||||
studysetId: studysetId,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,35 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// The tabs of a studyset's content, in their order.
|
||||
enum StudysetTab() {
|
||||
/// The kinds of a studyset's content, each in one of its parts.
|
||||
enum StudysetTab(
|
||||
/// The part that shows it.
|
||||
final StudysetPart part,
|
||||
) {
|
||||
/// Its learning paths.
|
||||
paths,
|
||||
paths(StudysetPart.plan),
|
||||
|
||||
/// Its flashcards.
|
||||
flashcards,
|
||||
flashcards(StudysetPart.material),
|
||||
|
||||
/// Its notes.
|
||||
notes,
|
||||
notes(StudysetPart.material),
|
||||
|
||||
/// Its files: its PDFs.
|
||||
files,
|
||||
files(StudysetPart.material),
|
||||
}
|
||||
|
||||
/// The parts of a studyset's screen, in their order: what it holds, how its
|
||||
/// studying goes, and what to study next.
|
||||
enum StudysetPart() {
|
||||
/// Its flashcards, notes and files.
|
||||
material,
|
||||
|
||||
/// How much is learnt, what is due, how long it was studied; once
|
||||
/// anything in it was studied.
|
||||
progress,
|
||||
|
||||
/// Its learning paths, and what to do next.
|
||||
plan,
|
||||
}
|
||||
|
||||
/// The last request to show a content tab, if any; `serial` tells two
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
||||
/// How a studyset was studied: whether anything in it was, and how long it
|
||||
/// was studied on each of the last seven days, today last.
|
||||
typedef StudysetActivityState = ({bool studied, List<Duration> week});
|
||||
|
||||
/// Reads how the studysets [_studysetIds] (a studyset and everything inside
|
||||
/// it) were studied, from the reviews of their cards on this device, and
|
||||
/// again after each review recorded; null until read.
|
||||
class StudysetActivityCubit(
|
||||
final CardRepository _cards,
|
||||
final ReviewRepository _reviews,
|
||||
final List<String> _studysetIds,
|
||||
) extends Cubit<StudysetActivityState?> {
|
||||
this : super(null) {
|
||||
_changes = _reviews.changes.listen((_) => unawaited(_read()));
|
||||
unawaited(_read());
|
||||
}
|
||||
|
||||
late final StreamSubscription<void> _changes;
|
||||
|
||||
Future<void> _read() async {
|
||||
try {
|
||||
final cardIds = <String>{
|
||||
for (final id in _studysetIds)
|
||||
for (final card in await _cards.cardsOf(id)) card.id,
|
||||
};
|
||||
final reviews = await _reviews.reviewsOf(cardIds);
|
||||
final now = clock.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final week = List.filled(7, Duration.zero);
|
||||
var studied = false;
|
||||
for (final review in reviews) {
|
||||
// A fresh start of a card's progress is no studying.
|
||||
if (!review.mode.rates) {
|
||||
continue;
|
||||
}
|
||||
studied = true;
|
||||
final at = review.reviewedAt.toLocal();
|
||||
// Rounded: a day with a clock change is 23 or 25 hours long.
|
||||
final daysAgo =
|
||||
today.difference(DateTime(at.year, at.month, at.day)).inHours / 24;
|
||||
final index = 6 - daysAgo.round();
|
||||
if (index >= 0 && index < 7) {
|
||||
week[index] += review.duration;
|
||||
}
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit((studied: studied, week: week));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Reading how a studyset was studied failed', error: error);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _changes.cancel();
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,934 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_list.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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The name of [part] in the app's language.
|
||||
String studysetPartName(AppLocalizations l10n, StudysetPart part) =>
|
||||
switch (part) {
|
||||
StudysetPart.material => l10n.studysetPartMaterial,
|
||||
StudysetPart.progress => l10n.studysetPartProgress,
|
||||
StudysetPart.plan => l10n.studysetPartPlan,
|
||||
};
|
||||
|
||||
/// The buttons between a studyset's [parts], one pill, the [current] one
|
||||
/// raised in it. Choosing another ticks, as the navigation bar does; the
|
||||
/// current one again is [onChosen] too (going back to its top).
|
||||
class const StudysetPartSwitch({
|
||||
required final List<StudysetPart> parts,
|
||||
required final StudysetPart current,
|
||||
required final ValueChanged<StudysetPart> onChosen,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
/// How high it is.
|
||||
static const height = 44.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final index = math.max(parts.indexOf(current), 0);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: ShapeDecoration(
|
||||
shape: const StadiumBorder(),
|
||||
color: colors.surfaceContainerHighest,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth / parts.length;
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: width * index,
|
||||
width: width,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: DecoratedBox(
|
||||
decoration: ShapeDecoration(
|
||||
shape: const StadiumBorder(),
|
||||
color: colors.surface,
|
||||
shadows: [
|
||||
BoxShadow(
|
||||
color: colors.shadow.withValues(alpha: 0.12),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
for (final part in parts)
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: part == current,
|
||||
inMutuallyExclusiveGroup: true,
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: () {
|
||||
if (part != current) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
onChosen(part);
|
||||
},
|
||||
child: Center(
|
||||
child: Text(
|
||||
studysetPartName(l10n, part),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: part == current
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: part == current
|
||||
? colors.onSurface
|
||||
: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulled down, a part syncs, however little it holds.
|
||||
Widget _pullToSync(
|
||||
BuildContext context,
|
||||
ScrollController scroll,
|
||||
List<Widget> slivers,
|
||||
) => RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
child: CustomScrollView(
|
||||
controller: scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: slivers,
|
||||
),
|
||||
);
|
||||
|
||||
/// Room below a part for the bar floating over it.
|
||||
const _foot = SliverToBoxAdapter(child: SizedBox(height: 96));
|
||||
|
||||
/// A studyset's material: its flashcards, notes and files, one section
|
||||
/// below the other, each with its first few and Show all for the rest.
|
||||
class const MaterialPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
||||
/// Whether the toolbar's buttons say what they do.
|
||||
required final bool labelled,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<MaterialPart> createState() => _MaterialPartState();
|
||||
}
|
||||
|
||||
class _MaterialPartState() extends State<MaterialPart> {
|
||||
/// How many of each a section shows until it shows all.
|
||||
static const _preview = 3;
|
||||
|
||||
/// The sections showing all they hold.
|
||||
final _expanded = <StudysetTab>{};
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
||||
/// Whether the files show handles to drag them, in their own order.
|
||||
var _reorderingFiles = false;
|
||||
|
||||
int? _limit(StudysetTab tab) => _expanded.contains(tab) ? null : _preview;
|
||||
|
||||
void _toggle(StudysetTab tab) => setState(
|
||||
() => _expanded.contains(tab) ? _expanded.remove(tab) : _expanded.add(tab),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final cards = context.watch<CardListBloc>().state;
|
||||
final notes = context.watch<NoteListBloc>().state;
|
||||
final files = context.watch<PdfListBloc>().state;
|
||||
const sidePadding = EdgeInsets.symmetric(horizontal: 12);
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingNotes && state.notes.length < 2) {
|
||||
setState(() => _reorderingNotes = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PdfListBloc, PdfListState>(
|
||||
listener: (context, state) {
|
||||
if (_reorderingFiles && state.rows.length < 2) {
|
||||
setState(() => _reorderingFiles = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
child: _pullToSync(context, widget.scroll, [
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
||||
..._section(
|
||||
tab: StudysetTab.flashcards,
|
||||
title: l10n.studysetTabFlashcards,
|
||||
count: switch (cards) {
|
||||
CardListStateLoaded(:final cards) => cards.length,
|
||||
_ => null,
|
||||
},
|
||||
shown: switch (cards) {
|
||||
CardListStateLoaded(:final shown) => shown.length,
|
||||
_ => 0,
|
||||
},
|
||||
actions: [
|
||||
BrowseCardsButton(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: widget.labelled,
|
||||
),
|
||||
// Narrowing them to a tag where they all show, or while they
|
||||
// are narrowed: otherwise the title keeps its room on a phone.
|
||||
if (_expanded.contains(StudysetTab.flashcards) ||
|
||||
switch (cards) {
|
||||
CardListStateLoaded(:final shown, :final filter) =>
|
||||
shown.length <= _preview || filter != null,
|
||||
_ => false,
|
||||
})
|
||||
const CardListToolbar(),
|
||||
],
|
||||
body: switch (cards) {
|
||||
CardListStateLoading() => const _SliverSectionLoading(),
|
||||
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
||||
icon: LucideIcons.layers,
|
||||
title: l10n.studysetFlashcardsEmpty,
|
||||
hint: l10n.studysetFlashcardsEmptyHint,
|
||||
),
|
||||
_ => SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
limit: _limit(StudysetTab.flashcards),
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
),
|
||||
},
|
||||
),
|
||||
..._section(
|
||||
tab: StudysetTab.notes,
|
||||
title: l10n.studysetTabNotes,
|
||||
count: notes.loaded ? notes.notes.length : null,
|
||||
shown: notes.notes.length,
|
||||
reorderable: (
|
||||
reordering: _reorderingNotes,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(() {
|
||||
_reorderingNotes = !_reorderingNotes;
|
||||
_expanded.add(StudysetTab.notes);
|
||||
}),
|
||||
),
|
||||
body: switch (notes) {
|
||||
NoteListState(loaded: false) => const _SliverSectionLoading(),
|
||||
NoteListState(notes: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.notebookPen,
|
||||
title: l10n.notesEmpty,
|
||||
hint: l10n.notesEmptyHint,
|
||||
),
|
||||
_ => SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
limit: _reorderingNotes ? null : _limit(StudysetTab.notes),
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
),
|
||||
..._section(
|
||||
tab: StudysetTab.files,
|
||||
title: l10n.studysetTabFiles,
|
||||
count: files.loaded ? files.rows.length : null,
|
||||
shown: files.rows.length,
|
||||
reorderable: (
|
||||
reordering: _reorderingFiles,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own order, whatever the
|
||||
// files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_reorderingFiles = !_reorderingFiles;
|
||||
_expanded.add(StudysetTab.files);
|
||||
});
|
||||
},
|
||||
),
|
||||
body: switch (files) {
|
||||
PdfListState(loaded: false) => const _SliverSectionLoading(),
|
||||
PdfListState(rows: [], failed: false) => _SliverEmptySection(
|
||||
icon: LucideIcons.folder,
|
||||
title: l10n.filesEmpty,
|
||||
hint: l10n.filesEmptyHint,
|
||||
),
|
||||
_ => SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
limit: _reorderingFiles ? null : _limit(StudysetTab.files),
|
||||
padding: sidePadding,
|
||||
),
|
||||
},
|
||||
),
|
||||
_foot,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// A section: its [title] with how many it holds ([count], once known),
|
||||
/// its [actions], Reorder where [reorderable] and it holds two or more,
|
||||
/// and Show all while it [shown] more than the first few; then [body].
|
||||
List<Widget> _section({
|
||||
required StudysetTab tab,
|
||||
required String title,
|
||||
required int? count,
|
||||
required int shown,
|
||||
required Widget body,
|
||||
List<Widget> actions = const [],
|
||||
({bool reordering, String tooltip, VoidCallback onToggle})? reorderable,
|
||||
}) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final expanded = _expanded.contains(tab);
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: count),
|
||||
],
|
||||
const Spacer(),
|
||||
...actions,
|
||||
if (reorderable != null &&
|
||||
((count ?? 0) >= 2 || reorderable.reordering))
|
||||
ReorderToggle(
|
||||
reordering: reorderable.reordering,
|
||||
labelled: widget.labelled,
|
||||
tooltip: reorderable.tooltip,
|
||||
onToggle: reorderable.onToggle,
|
||||
),
|
||||
if (shown > _preview && reorderable?.reordering != true)
|
||||
TextButton(
|
||||
onPressed: () => _toggle(tab),
|
||||
child: Text(
|
||||
expanded ? l10n.studysetShowFewer : l10n.studysetShowAll,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
body,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// How many a section holds, in a small box beside its title.
|
||||
class const _CountBadge({required final int count}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Text('$count', style: theme.textTheme.labelMedium),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section while its list is read.
|
||||
class const _SliverSectionLoading() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: AppLoadingIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A section with nothing in it yet: what it would hold, and how to add
|
||||
/// it, in an outlined box.
|
||||
class const _SliverEmptySection({
|
||||
required final IconData icon,
|
||||
required final String title,
|
||||
required final String hint,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 28, color: colors.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse beside the flashcards, while there are cards: opens the card
|
||||
/// browser of the studyset [studysetId], over everything inside it too
|
||||
/// when there is something. [labelled] says Browse, else an icon alone.
|
||||
class const BrowseCardsButton({
|
||||
required final String studysetId,
|
||||
required final bool labelled,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCards = context.select<CardListBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
CardListStateLoaded(:final cards) => cards.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
if (!hasCards) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void open() => context.push(
|
||||
RoutePaths.cardBrowser(
|
||||
studysetId,
|
||||
// The rail's list: the studysets inside this one.
|
||||
subtree: switch (context.read<StudysetListBloc>().state) {
|
||||
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
),
|
||||
);
|
||||
return labelled
|
||||
? TextButton.icon(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid, size: 18),
|
||||
label: Text(l10n.cardBrowserBrowse),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid),
|
||||
tooltip: l10n.cardBrowserBrowseTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The studyset [studysetId]'s progress, with everything inside it: how much
|
||||
/// is learnt, what is due today, and how long it was studied this week.
|
||||
/// Shown once anything in it was studied.
|
||||
class const ProgressPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final progress = context.select<StudysetListBloc, StudyProgress?>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) => progress[studysetId],
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
final activity = context.watch<StudysetActivityCubit>().state;
|
||||
if (progress == null || activity == null) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
final learned = progress.mastery ?? 0;
|
||||
final studied = activity.week.fold(Duration.zero, (a, b) => a + b);
|
||||
final big = theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
);
|
||||
final muted = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
);
|
||||
final toReview = _Tile(
|
||||
title: l10n.progressToReview,
|
||||
info: l10n.progressToReviewInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${progress.reviewsDue}', style: big),
|
||||
Text(l10n.progressDueToday(progress.reviewsDue), style: muted),
|
||||
],
|
||||
),
|
||||
);
|
||||
final studyTime = _Tile(
|
||||
title: l10n.progressStudyTime,
|
||||
info: l10n.progressStudyTimeInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_duration(l10n, studied), style: big, maxLines: 1),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(l10n.progressThisWeek, style: muted)),
|
||||
CustomPaint(
|
||||
size: const Size(56, 24),
|
||||
painter: _Sparkline(
|
||||
values: [
|
||||
for (final day in activity.week) day.inSeconds.toDouble(),
|
||||
],
|
||||
color: theme.extras.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return _pullToSync(context, scroll, [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
_Tile(
|
||||
title: l10n.progressLearned,
|
||||
info: l10n.progressLearnedInfo,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text('${(learned * 100).round()} %', style: big),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(l10n.progressOfStudyset, style: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: learned,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
// A plain track: the theme's tinted one reads as full
|
||||
// at 0 % in dark mode.
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
stopIndicatorRadius: 0,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.layers,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.progressCardsLearned(
|
||||
progress.mature,
|
||||
progress.cards,
|
||||
),
|
||||
style: muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Side by side where both fit, as on most phones.
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => constraints.maxWidth >= 340
|
||||
? IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: toReview),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: studyTime),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
toReview,
|
||||
const SizedBox(height: 12),
|
||||
studyTime,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_foot,
|
||||
]);
|
||||
}
|
||||
|
||||
static String _duration(AppLocalizations l10n, Duration duration) {
|
||||
final minutes = (duration.inSeconds / 60).round();
|
||||
return minutes < 60
|
||||
? l10n.progressMinutes(minutes)
|
||||
: l10n.progressHoursMinutes(minutes ~/ 60, minutes % 60);
|
||||
}
|
||||
}
|
||||
|
||||
/// A box of the progress: its [title] with what it means ([info]) behind
|
||||
/// the ⓘ, then [child].
|
||||
class const _Tile({
|
||||
required final String title,
|
||||
required final String info,
|
||||
required final Widget child,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: info,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [values] as a line, the largest at the top; flat along the foot for
|
||||
/// none.
|
||||
class const _Sparkline({
|
||||
required final List<double> values,
|
||||
required final Color color,
|
||||
}) extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final top = values.fold<double>(0, math.max);
|
||||
final path = Path();
|
||||
for (final (index, value) in values.indexed) {
|
||||
final point = Offset(
|
||||
size.width * index / (values.length - 1),
|
||||
size.height - 2 - (top == 0 ? 0 : (size.height - 4) * value / top),
|
||||
);
|
||||
index == 0
|
||||
? path.moveTo(point.dx, point.dy)
|
||||
: path.lineTo(point.dx, point.dy);
|
||||
}
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..color = color,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Sparkline oldDelegate) =>
|
||||
oldDelegate.color != color || !listEquals(oldDelegate.values, values);
|
||||
}
|
||||
|
||||
/// The studyset [studysetId]'s plan: how many of its learning paths are
|
||||
/// finished, what to do next (study the cards due, go on with the first
|
||||
/// path not finished), then the paths.
|
||||
class const PlanPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
||||
/// Whether Reorder says what it does.
|
||||
required final bool labelled,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<PlanPart> createState() => _PlanPartState();
|
||||
}
|
||||
|
||||
class _PlanPartState() extends State<PlanPart> {
|
||||
/// Whether the paths show handles to drag them.
|
||||
var _reordering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final paths = context.watch<PathListBloc>().state;
|
||||
final due = context.select<StudysetListBloc, int>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) =>
|
||||
progress[widget.studysetId]?.due ?? 0,
|
||||
_ => 0,
|
||||
},
|
||||
);
|
||||
final total = paths.paths.length;
|
||||
final done = paths.paths
|
||||
.where((path) => paths.completed.contains(path.id))
|
||||
.length;
|
||||
final next = paths.completionKnown
|
||||
? paths.paths
|
||||
.where((path) => !paths.completed.contains(path.id))
|
||||
.firstOrNull
|
||||
: null;
|
||||
final tasks = [
|
||||
if (due > 0)
|
||||
_Task(
|
||||
icon: LucideIcons.layers,
|
||||
title: l10n.planReview(due),
|
||||
onTap: () => startStudying(context, widget.studysetId),
|
||||
),
|
||||
if (next != null)
|
||||
_Task(
|
||||
icon: LucideIcons.route,
|
||||
title: l10n.planContinue(next.content.title),
|
||||
onTap: () =>
|
||||
context.push(RoutePaths.path(widget.studysetId, next.id)),
|
||||
),
|
||||
];
|
||||
return BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reordering && state.paths.length < 2) {
|
||||
setState(() => _reordering = false);
|
||||
}
|
||||
},
|
||||
child: _pullToSync(context, widget.scroll, [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 56,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: total == 0 ? 0 : done / total,
|
||||
strokeWidth: 6,
|
||||
backgroundColor: colors.surfaceContainerHighest,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${total == 0 ? 0 : (done * 100 / total).round()}%',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
total == 0
|
||||
? l10n.planNoPaths
|
||||
: l10n.planPathsDone(done, total),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
child: Text(l10n.planNextUp, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
),
|
||||
if (tasks.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Text(
|
||||
l10n.planNothing,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
sliver: SliverList.list(children: tasks),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 8, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.planLearningPaths,
|
||||
style: theme.textTheme.titleMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CountBadge(count: total),
|
||||
const Spacer(),
|
||||
if (total >= 2 || _reordering)
|
||||
ReorderToggle(
|
||||
reordering: _reordering,
|
||||
labelled: widget.labelled,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(() => _reordering = !_reordering),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reordering,
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
),
|
||||
_foot,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Something to do next: a row that does it on a tap.
|
||||
class const _Task({
|
||||
required final IconData icon,
|
||||
required final String title,
|
||||
required final VoidCallback onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
tileColor: colors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
leading: Icon(icon, color: colors.primary),
|
||||
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
||||
|
|
@ -14,13 +13,10 @@ import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/layout/readable_width.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/layout/window_size.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/brand.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/palette_color.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/reorder_toggle.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/scroll_hiding_fab.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
|
||||
|
|
@ -30,7 +26,6 @@ import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
|
||||
|
|
@ -46,14 +41,13 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_p
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/pdf_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
|
|
@ -64,6 +58,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actio
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_info.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -267,8 +262,6 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget build(BuildContext context) {
|
||||
final studyset = widget.studyset;
|
||||
final all = widget.all;
|
||||
final theme = Theme.of(context);
|
||||
final wide = context.windowSize.isAtLeastExpanded;
|
||||
// The topics, with the bibliography at their foot across from New
|
||||
// sub-studyset: in the side bar from expanded windows on, below that in
|
||||
// a sheet ([_showTopics]).
|
||||
|
|
@ -278,13 +271,39 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
onAddMaterial: _addMaterial.open,
|
||||
footEnd: _BibliographyButton(studyset: studyset),
|
||||
);
|
||||
final content = _Content(studysetId: studyset.id);
|
||||
final description = studyset.content.description.isBlank
|
||||
? null
|
||||
: _Description(studyset: studyset);
|
||||
final color =
|
||||
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
|
||||
Brand.primary;
|
||||
return _Parts(
|
||||
studysetId: studyset.id,
|
||||
all: all,
|
||||
builder: (context, parts) => _withFab(
|
||||
context,
|
||||
rail,
|
||||
_Content(studysetId: studyset.id, parts: parts),
|
||||
parts,
|
||||
description,
|
||||
color,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The screen, with what floats over it, around [content] and the
|
||||
/// switch between its [parts].
|
||||
Widget _withFab(
|
||||
BuildContext context,
|
||||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description,
|
||||
Color color,
|
||||
) {
|
||||
final studyset = widget.studyset;
|
||||
final theme = Theme.of(context);
|
||||
final wide = context.windowSize.isAtLeastExpanded;
|
||||
return ScrollHidingFab(
|
||||
// From expanded windows on, Study and Add material are in the app
|
||||
// bar, and asking Claude floats alone.
|
||||
|
|
@ -319,6 +338,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
fab,
|
||||
rail,
|
||||
content,
|
||||
parts,
|
||||
description,
|
||||
roomy: constraints.maxWidth >= _roomyWidth,
|
||||
),
|
||||
|
|
@ -327,6 +347,9 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// How wide each part's button is in the path's line.
|
||||
static const _partWidth = 120.0;
|
||||
|
||||
/// How wide the screen has to be for Add material to show its label in
|
||||
/// the app bar; below, it is an icon, so Study, Share and the options fit.
|
||||
static const _roomyWidth = 960.0;
|
||||
|
|
@ -336,6 +359,7 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
Widget? fab,
|
||||
Widget rail,
|
||||
Widget content,
|
||||
_PartsView parts,
|
||||
Widget? description, {
|
||||
required bool roomy,
|
||||
}) {
|
||||
|
|
@ -348,6 +372,16 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
final busy =
|
||||
context.select<PdfListBloc, bool>((bloc) => bloc.state.importing) ||
|
||||
context.select<PathListBloc, bool>((bloc) => bloc.state.importing);
|
||||
// In the header, as Vaia has it (decided 2026-10-12): below expanded
|
||||
// windows in a line of its own under the title, from there on at the end
|
||||
// of the path's line, which the topics leave free there.
|
||||
final partSwitch = parts.settled
|
||||
? StudysetPartSwitch(
|
||||
parts: parts.parts,
|
||||
current: parts.shown,
|
||||
onChosen: parts.show,
|
||||
)
|
||||
: null;
|
||||
return PopScope(
|
||||
canPop: !busy,
|
||||
child: Scaffold(
|
||||
|
|
@ -427,13 +461,27 @@ class _StudysetViewState() extends State<_StudysetView> {
|
|||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!wide)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
// Its room kept while the part to show is settled.
|
||||
child:
|
||||
partSwitch ??
|
||||
const SizedBox(height: StudysetPartSwitch.height),
|
||||
),
|
||||
StudysetBreadcrumb(
|
||||
ancestors: ancestorsOf(studyset.id, all),
|
||||
current: studyset,
|
||||
all: all,
|
||||
enabled: !busy,
|
||||
trailing: wide
|
||||
? null
|
||||
? switch (partSwitch) {
|
||||
final partSwitch? => SizedBox(
|
||||
width: _partWidth * parts.parts.length,
|
||||
child: partSwitch,
|
||||
),
|
||||
null => null,
|
||||
}
|
||||
: _TopicsButton(
|
||||
count: context.select<StudysetListBloc, int>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
|
|
@ -601,438 +649,213 @@ class const _BibliographyButton({required final Studyset studyset})
|
|||
}
|
||||
}
|
||||
|
||||
/// The studyset's content, in tabs that appear as their features land:
|
||||
/// learning paths, flashcards, notes and files, each filling the card below
|
||||
/// the tabs. Beside the tabs, what the tab shown offers: on the flashcards
|
||||
/// narrowing them to a tag and managing tags, on the paths reordering them
|
||||
/// once there are two.
|
||||
/// Which part of the studyset shows, as the switch above it and the content
|
||||
/// share it: whether that is settled yet, the parts there are, the one
|
||||
/// shown, whether anything was studied, each part's scrolling, and how to
|
||||
/// show another.
|
||||
typedef _PartsView = ({
|
||||
bool settled,
|
||||
List<StudysetPart> parts,
|
||||
StudysetPart shown,
|
||||
bool studied,
|
||||
Map<StudysetPart, ScrollController> scrolls,
|
||||
ValueChanged<StudysetPart> show,
|
||||
});
|
||||
|
||||
/// The studyset's content in three parts, chosen with the switch in the
|
||||
/// header: Material (its flashcards, notes and files, a section each),
|
||||
/// Progress (how much is learnt, what is due, the study time this week;
|
||||
/// only once anything in it was studied) and Study plan (its learning
|
||||
/// paths, and what to do next). [builder] gets which shows.
|
||||
///
|
||||
/// It opens on the tab it was left on, on this device ([StudysetTabs],
|
||||
/// It opens on the part it was left on, on this device ([StudysetTabs],
|
||||
/// decided 2026-10-11). Opened here for the first time, it opens on the
|
||||
/// first tab with something of the studyset's own in it (its topics'
|
||||
/// content is in theirs), in their order: paths not completed yet,
|
||||
/// flashcards, notes, files; on the flashcards when there is nothing
|
||||
/// (decided 2026-10-03); until it knows which, it shows that it is loading
|
||||
/// rather than one tab and then another. Material added or imported shows in
|
||||
/// its own tab ([ContentTabCubit]). Once the person chose a tab, or material
|
||||
/// was added, opening no longer moves it.
|
||||
class const _Content({required final String studysetId})
|
||||
extends StatefulWidget {
|
||||
/// study plan while a path is not completed yet, else on the material;
|
||||
/// until it knows which, it shows that it is loading rather than one part
|
||||
/// and then another. Material added or imported shows in its part
|
||||
/// ([ContentTabCubit]). Once the person chose a part, or material was
|
||||
/// added, opening no longer moves it.
|
||||
class const _Parts({
|
||||
required final String studysetId,
|
||||
|
||||
/// All the person's studysets, for what is inside this one.
|
||||
required final List<Studyset> all,
|
||||
required final Widget Function(BuildContext context, _PartsView parts)
|
||||
builder,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_Content> createState() => _ContentState();
|
||||
State<_Parts> createState() => _PartsState();
|
||||
}
|
||||
|
||||
class _ContentState()
|
||||
extends State<_Content>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _tabs = TabController(
|
||||
length: StudysetTab.values.length,
|
||||
vsync: this,
|
||||
initialIndex: _active,
|
||||
);
|
||||
|
||||
/// The tab each studyset was left on, on this device.
|
||||
class _PartsState() extends State<_Parts> {
|
||||
/// The part each studyset was left on, on this device.
|
||||
late final StudysetTabs _kept = PersonScope.of(context).studysetTabs;
|
||||
|
||||
/// Whether the tab shown is settled: the one it was left on, chosen on
|
||||
/// opening, by the person, or for material added. Until then, no tab
|
||||
/// Whether the part shown is settled: the one it was left on, chosen on
|
||||
/// opening, by the person, or for material added. Until then, no part
|
||||
/// shows.
|
||||
var _settled = false;
|
||||
|
||||
/// Whether the paths show handles to drag them.
|
||||
var _reorderingPaths = false;
|
||||
/// The part shown, once settled.
|
||||
var _part = StudysetPart.material;
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
||||
/// Whether the files show handles to drag them, in their own order.
|
||||
var _reorderingFiles = false;
|
||||
|
||||
/// Each tab's scrolling, to go back to its top.
|
||||
final _scrolls = [for (final _ in StudysetTab.values) ScrollController()];
|
||||
|
||||
/// The tab shown, once the tabs have settled.
|
||||
var _active = StudysetTab.flashcards.index;
|
||||
|
||||
/// Whether the studyset itself holds cards, notes and PDFs, read once on
|
||||
/// opening; null until then.
|
||||
({bool cards, bool notes, bool pdfs})? _holds;
|
||||
/// Each part's scrolling, to go back to its top.
|
||||
final _scrolls = {
|
||||
for (final part in StudysetPart.values) part: ScrollController(),
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (StudysetTab.values.asNameMap()[_kept.of(widget.studysetId)]
|
||||
case final tab?) {
|
||||
_active = tab.index;
|
||||
final kept = _kept.of(widget.studysetId);
|
||||
// A part, or a tab kept before there were parts.
|
||||
if (StudysetPart.values.asNameMap()[kept] ??
|
||||
StudysetTab.values.asNameMap()[kept]?.part
|
||||
case final part?) {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
}
|
||||
_tabs.addListener(() {
|
||||
// Swiping to a tab is choosing it as much as tapping it is.
|
||||
_settle();
|
||||
if (!_tabs.indexIsChanging) {
|
||||
_active = _tabs.index;
|
||||
unawaited(
|
||||
_kept.keep(widget.studysetId, StudysetTab.values[_active].name),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!_settled) {
|
||||
unawaited(_readWhatItHolds());
|
||||
}
|
||||
}
|
||||
|
||||
void _settle() {
|
||||
if (!_settled && mounted) {
|
||||
setState(() => _settled = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabs.dispose();
|
||||
for (final scroll in _scrolls) {
|
||||
for (final scroll in _scrolls.values) {
|
||||
scroll.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// A tap on the tab shown goes back to its top.
|
||||
void _tapped(int index) {
|
||||
final scroll = _scrolls[index];
|
||||
if (index == _active && scroll.hasClients) {
|
||||
/// Shows [part], kept for the next time; chosen again, it goes back to
|
||||
/// its top.
|
||||
void _show(StudysetPart part) {
|
||||
final scroll = _scrolls[part]!;
|
||||
if (_settled && part == _part && scroll.hasClients) {
|
||||
scroll.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
}
|
||||
|
||||
Future<void> _readWhatItHolds() async {
|
||||
final person = PersonScope.of(context);
|
||||
try {
|
||||
final cards = await person.cards.cardsOf(widget.studysetId);
|
||||
final notes = await person.notes.countsByStudyset();
|
||||
final pdfs = await person.pdfSources.countsByStudyset();
|
||||
_holds = (
|
||||
cards: cards.isNotEmpty,
|
||||
notes: notes.containsKey(widget.studysetId),
|
||||
pdfs: pdfs.containsKey(widget.studysetId),
|
||||
);
|
||||
} on Object catch (error) {
|
||||
log(
|
||||
'Reading what a studyset holds failed',
|
||||
name: 'Studyset',
|
||||
error: error,
|
||||
);
|
||||
if (mounted && !_settled) {
|
||||
_show(StudysetTab.flashcards, opening: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_chooseOnOpening();
|
||||
}
|
||||
|
||||
/// Shows [tab]: at once while [opening], sliding over otherwise.
|
||||
void _show(StudysetTab tab, {bool opening = false}) {
|
||||
_settle();
|
||||
if (_tabs.index != tab.index) {
|
||||
if (opening) {
|
||||
_tabs.index = tab.index;
|
||||
} else {
|
||||
_tabs.animateTo(tab.index);
|
||||
}
|
||||
} else {
|
||||
unawaited(_kept.keep(widget.studysetId, tab.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses the tab to open on, once enough is known: paths in progress
|
||||
/// win at once; the others once both are known.
|
||||
void _chooseOnOpening() {
|
||||
/// Chooses the part to open on, once the paths are known: the plan while
|
||||
/// one is in progress, else the material.
|
||||
void _chooseOnOpening(PathListState paths) {
|
||||
if (_settled || !mounted) {
|
||||
return;
|
||||
}
|
||||
final paths = context.read<PathListBloc>().state;
|
||||
// Paths that can't be read are as good as none.
|
||||
final pathsKnown = paths.completionKnown || paths.failed;
|
||||
final StudysetPart part;
|
||||
if (paths.completionKnown && paths.hasOpenPaths) {
|
||||
_show(StudysetTab.paths, opening: true);
|
||||
} else if (pathsKnown && _holds != null) {
|
||||
_show(switch (_holds!) {
|
||||
(cards: false, notes: true, pdfs: _) => StudysetTab.notes,
|
||||
(cards: false, notes: false, pdfs: true) => StudysetTab.files,
|
||||
_ => StudysetTab.flashcards,
|
||||
}, opening: true);
|
||||
part = StudysetPart.plan;
|
||||
} else if (paths.completionKnown || paths.failed) {
|
||||
part = StudysetPart.material;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_part = part;
|
||||
_settled = true;
|
||||
});
|
||||
unawaited(_kept.keep(widget.studysetId, part.name));
|
||||
}
|
||||
|
||||
/// The tab [tab], scrolling through [list], the slivers of its list: rows
|
||||
/// are built as they scroll into view. Pulled down, it syncs.
|
||||
Widget _tab(StudysetTab tab, Widget list) => RefreshIndicator(
|
||||
onRefresh: () => syncAndWait(context.read<SyncStatusBloc>()),
|
||||
child: CustomScrollView(
|
||||
controller: _scrolls[tab.index],
|
||||
// Pullable however little it holds.
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [list],
|
||||
),
|
||||
);
|
||||
|
||||
/// Around a tab's list; room below for the bar floating over it.
|
||||
static const _listPadding = EdgeInsets.fromLTRB(0, 8, 0, 96);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ContentTabCubit, ContentTabState>(
|
||||
listener: (context, request) {
|
||||
if (request != null) {
|
||||
_show(request.tab);
|
||||
final person = PersonScope.of(context);
|
||||
return BlocProvider(
|
||||
create: (context) => StudysetActivityCubit(
|
||||
person.cards,
|
||||
person.reviews,
|
||||
subtreeOf(widget.studysetId, widget.all),
|
||||
),
|
||||
child: MultiBlocListener(
|
||||
listeners: [
|
||||
BlocListener<ContentTabCubit, ContentTabState>(
|
||||
listener: (context, request) {
|
||||
if (request != null) {
|
||||
_show(request.tab.part);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) => _chooseOnOpening(state),
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (!_settled) {
|
||||
// The paths may be known already.
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _chooseOnOpening(context.read<PathListBloc>().state),
|
||||
);
|
||||
}
|
||||
// Progress once anything in it was studied: a card, or a path
|
||||
// finished.
|
||||
final studied =
|
||||
(context.watch<StudysetActivityCubit>().state?.studied ??
|
||||
false) ||
|
||||
context.select<PathListBloc, bool>(
|
||||
(bloc) => bloc.state.completed.isNotEmpty,
|
||||
);
|
||||
final parts = [
|
||||
StudysetPart.material,
|
||||
if (studied) StudysetPart.progress,
|
||||
StudysetPart.plan,
|
||||
];
|
||||
return widget.builder(context, (
|
||||
settled: _settled,
|
||||
parts: parts,
|
||||
shown: parts.contains(_part) ? _part : StudysetPart.material,
|
||||
studied: studied,
|
||||
scrolls: _scrolls,
|
||||
show: _show,
|
||||
));
|
||||
},
|
||||
),
|
||||
BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) {
|
||||
_chooseOnOpening();
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingPaths && state.paths.length < 2) {
|
||||
setState(() => _reorderingPaths = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the studyset [parts] shows; a spinner until that is
|
||||
/// settled.
|
||||
class const _Content({
|
||||
required final String studysetId,
|
||||
required final _PartsView parts,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!parts.settled) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
// Buttons say what they do where there is room.
|
||||
final labelled = MediaQuery.sizeOf(context).width >= 480;
|
||||
return IndexedStack(
|
||||
index: parts.shown.index,
|
||||
children: [
|
||||
MaterialPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.material]!,
|
||||
labelled: labelled,
|
||||
),
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
listener: (context, state) {
|
||||
// Nothing left to reorder.
|
||||
if (_reorderingNotes && state.notes.length < 2) {
|
||||
setState(() => _reorderingNotes = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
BlocListener<PdfListBloc, PdfListState>(
|
||||
listener: (context, state) {
|
||||
if (_reorderingFiles && state.rows.length < 2) {
|
||||
setState(() => _reorderingFiles = false);
|
||||
}
|
||||
},
|
||||
if (parts.studied)
|
||||
ProgressPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.progress]!,
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
PlanPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.plan]!,
|
||||
labelled: labelled,
|
||||
),
|
||||
],
|
||||
child: !_settled
|
||||
? const Center(child: AppLoadingIndicator())
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: _tabs,
|
||||
onTap: _tapped,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// The line below runs the card's whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
for (final tab in StudysetTab.values)
|
||||
Tab(
|
||||
text: switch (tab) {
|
||||
StudysetTab.paths => l10n.studysetTabPaths,
|
||||
StudysetTab.flashcards =>
|
||||
l10n.studysetTabFlashcards,
|
||||
StudysetTab.notes => l10n.studysetTabNotes,
|
||||
StudysetTab.files => l10n.studysetTabFiles,
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _tabs,
|
||||
builder: (context, _) => switch (StudysetTab
|
||||
.values[_tabs.index]) {
|
||||
StudysetTab.flashcards => Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_Browse(
|
||||
studysetId: widget.studysetId,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
),
|
||||
const CardListToolbar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
StudysetTab.paths =>
|
||||
BlocSelector<PathListBloc, PathListState, int>(
|
||||
selector: (state) => state.paths.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingPaths,
|
||||
// Below that, an icon alone.
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderPaths,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingPaths = !_reorderingPaths,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.notes =>
|
||||
BlocSelector<NoteListBloc, NoteListState, int>(
|
||||
selector: (state) => state.notes.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingNotes,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderNotes,
|
||||
onToggle: () => setState(
|
||||
() => _reorderingNotes = !_reorderingNotes,
|
||||
),
|
||||
),
|
||||
),
|
||||
StudysetTab.files =>
|
||||
BlocSelector<PdfListBloc, PdfListState, int>(
|
||||
selector: (state) => state.rows.length,
|
||||
builder: (context, count) => _ReorderBeside(
|
||||
count: count,
|
||||
reordering: _reorderingFiles,
|
||||
labelled: constraints.maxWidth >= 480,
|
||||
tooltip: l10n.studysetReorderFiles,
|
||||
onToggle: () {
|
||||
if (!_reorderingFiles) {
|
||||
// What is dragged is the person's own
|
||||
// order, whatever the files were sorted by.
|
||||
context.read<PdfListBloc>().add(
|
||||
const PdfListEventSorted(PdfSort.custom),
|
||||
);
|
||||
}
|
||||
setState(
|
||||
() => _reorderingFiles = !_reorderingFiles,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabs,
|
||||
children: [
|
||||
_tab(
|
||||
StudysetTab.paths,
|
||||
SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingPaths,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.flashcards,
|
||||
SliverCardList(
|
||||
cards: PersonScope.of(context).cards,
|
||||
studysetId: widget.studysetId,
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 96),
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.notes,
|
||||
SliverNoteList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingNotes,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
_tab(
|
||||
StudysetTab.files,
|
||||
SliverPdfList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingFiles,
|
||||
padding: _listPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse beside the flashcards' tab, while it has cards: opens the card
|
||||
/// browser of the studyset [studysetId], over everything inside it too
|
||||
/// when there is something. [labelled] says Browse, else an icon alone.
|
||||
class const _Browse({
|
||||
required final String studysetId,
|
||||
required final bool labelled,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCards = context.select<CardListBloc, bool>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
CardListStateLoaded(:final cards) => cards.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
);
|
||||
if (!hasCards) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = AppLocalizations.of(context);
|
||||
void open() => context.push(
|
||||
RoutePaths.cardBrowser(
|
||||
studysetId,
|
||||
// The rail's list: the studysets inside this one.
|
||||
subtree: switch (context.read<StudysetListBloc>().state) {
|
||||
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
|
||||
_ => false,
|
||||
},
|
||||
),
|
||||
);
|
||||
return labelled
|
||||
? TextButton.icon(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid, size: 18),
|
||||
label: Text(l10n.cardBrowserBrowse),
|
||||
)
|
||||
: IconButton(
|
||||
onPressed: open,
|
||||
icon: const Icon(LucideIcons.layoutGrid),
|
||||
tooltip: l10n.cardBrowserBrowseTooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beside the tabs while a list that can be reordered shows, once it has
|
||||
/// [count] two or more: Reorder, and Done while [reordering]; [labelled]
|
||||
/// with its word, else an icon alone whose [tooltip] says what it reorders.
|
||||
class const _ReorderBeside({
|
||||
required final int count,
|
||||
required final bool reordering,
|
||||
required final bool labelled,
|
||||
required final String tooltip,
|
||||
required final VoidCallback onToggle,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (count < 2 && !reordering) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: ReorderToggle(
|
||||
reordering: reordering,
|
||||
labelled: labelled,
|
||||
tooltip: tooltip,
|
||||
onToggle: onToggle,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,14 +143,13 @@ void main() {
|
|||
expect(find.textContaining('Japanese'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets("on $window, a long studyset's screen, its tabs and topics", (
|
||||
testWidgets("on $window, a long studyset's screen, its parts and topics", (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, RoutePaths.studyset('grammar'));
|
||||
|
||||
for (final tab in ['Paths', 'Flashcards', 'Notes']) {
|
||||
await tester.tap(find.text(tab).first);
|
||||
await tester.pumpAndSettle();
|
||||
for (final part in ['Study plan', 'Material']) {
|
||||
await openPart(tester, part);
|
||||
}
|
||||
if (width < 840) {
|
||||
await tester.tap(find.byTooltip('Show topics'));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -33,11 +34,6 @@ void main() {
|
|||
noteGateway = FakeNoteGateway();
|
||||
});
|
||||
|
||||
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||
/// 2 notes.
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
/// A note in the studyset, so that it opens on its notes.
|
||||
Future<void> withNote() async {
|
||||
final notes = NoteRepository(noteGateway);
|
||||
|
|
@ -206,31 +202,33 @@ void main() {
|
|||
expect(await repository.all(), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('shows the flashcards after a deck came into this studyset, '
|
||||
'whatever tab was open', (tester) async {
|
||||
await withNote();
|
||||
await startImport(tester, fixture('anki_latest.apkg'));
|
||||
testWidgets('shows the material after a deck came into this studyset, '
|
||||
'whatever part was open', (tester) async {
|
||||
files.next = (name: 'japanese.apkg', bytes: fixture('anki_latest.apkg'));
|
||||
await showStudyset(tester);
|
||||
await openPart(tester, 'Study plan');
|
||||
await chooseImport(tester);
|
||||
await tester.tap(find.text('Tag cards by subdeck'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 1);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('leaves the tab as it was when the subdecks became '
|
||||
testWidgets('leaves the part as it was when the subdecks became '
|
||||
'sub-studysets', (tester) async {
|
||||
await withNote();
|
||||
files.next = (name: 'japanese.apkg', bytes: fixture('anki_latest.apkg'));
|
||||
await showStudyset(tester);
|
||||
expect(shownTab(tester), 2);
|
||||
await openPart(tester, 'Study plan');
|
||||
await chooseImport(tester);
|
||||
await tester.tap(find.text('Recreate as sub-studysets'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
});
|
||||
|
||||
testWidgets('warns when the files may not fit in the room left', (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
|
|
@ -49,11 +50,6 @@ void main() {
|
|||
noteGateway = FakeNoteGateway();
|
||||
});
|
||||
|
||||
/// The index of the studyset's content tab shown: 0 paths, 1 flashcards,
|
||||
/// 2 notes.
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
Future<void> showApp(
|
||||
WidgetTester tester,
|
||||
String location, {
|
||||
|
|
@ -120,8 +116,9 @@ void main() {
|
|||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a studyset opens again on the tab it was left on, here, '
|
||||
'even with a path unfinished', (tester) async {
|
||||
testWidgets('a studyset opens again on the part it was left on, here, '
|
||||
'even with a path unfinished; a tab kept before there were parts '
|
||||
'counts as its part', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
|
|
@ -135,10 +132,10 @@ void main() {
|
|||
tabs: MemoryStudysetTabStore({'japanese': 'notes'}),
|
||||
);
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('the tab a studyset shows, chosen on opening or by the '
|
||||
testWidgets('the part a studyset shows, chosen on opening or by the '
|
||||
'person, is kept for the next time', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
|
|
@ -149,12 +146,11 @@ void main() {
|
|||
final tabs = MemoryStudysetTabStore();
|
||||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'), tabs: tabs);
|
||||
expect(shownTab(tester), 0);
|
||||
expect(tabs.kept, {'japanese': 'paths'});
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(tabs.kept, {'japanese': 'plan'});
|
||||
|
||||
await tester.tap(find.widgetWithText(Tab, 'Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tabs.kept, {'japanese': 'notes'});
|
||||
await openPart(tester, 'Material');
|
||||
expect(tabs.kept, {'japanese': 'material'});
|
||||
});
|
||||
|
||||
testWidgets('a studyset with only notes opens on them', (tester) async {
|
||||
|
|
@ -167,7 +163,7 @@ void main() {
|
|||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -187,7 +183,7 @@ void main() {
|
|||
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(shownTab(tester), 1);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
});
|
||||
|
||||
testWidgets('without paths, the studyset opens on its flashcards', (
|
||||
|
|
@ -196,8 +192,7 @@ void main() {
|
|||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
expect(find.text('No flashcards yet'), findsOneWidget);
|
||||
await tester.tap(find.text('Paths'));
|
||||
await tester.pumpAndSettle();
|
||||
await openPart(tester, 'Study plan');
|
||||
expect(find.text('No paths yet'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -294,7 +289,7 @@ void main() {
|
|||
);
|
||||
expect(find.textContaining('1 block was left out'), findsOneWidget);
|
||||
// The paths show by themselves.
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(find.text('Particles'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -332,7 +327,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('an import shows the paths tab at once, and how far it has '
|
||||
testWidgets('an import shows the study plan at once, and how far it has '
|
||||
'come above the paths it adds', (tester) async {
|
||||
Uint8List path(String title) => utf8.encode(
|
||||
'{"path": {"title": "$title", "steps": ['
|
||||
|
|
@ -345,9 +340,7 @@ void main() {
|
|||
final hold = Completer<void>();
|
||||
pathGateway.holdSteps = hold;
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
await tester.tap(find.text('Flashcards').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(shownTab(tester), isNot(0));
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
|
|
@ -362,7 +355,7 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(
|
||||
find.text('Importing path 1 of 2: “State-of-being”…'),
|
||||
findsOneWidget,
|
||||
|
|
@ -380,8 +373,8 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('a note written from Add material shows in the Notes tab on '
|
||||
'coming back, as flashcards do in theirs', (tester) async {
|
||||
testWidgets('a note written from Add material shows in the material on '
|
||||
'coming back, as flashcards do', (tester) async {
|
||||
final paths = repository();
|
||||
final id = await paths.createPath(
|
||||
'japanese',
|
||||
|
|
@ -389,7 +382,7 @@ void main() {
|
|||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
expect(shownTab(tester), 0);
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
|
||||
await tester.tap(find.text('Add material'));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -399,7 +392,7 @@ void main() {
|
|||
await tester.pageBack();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shownTab(tester), 2);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Topic or subject'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
|
|||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -92,9 +93,6 @@ void main() {
|
|||
await choose(tester, 'Upload a PDF');
|
||||
}
|
||||
|
||||
int shownTab(WidgetTester tester) =>
|
||||
tester.widget<TabBar>(find.byType(TabBar)).controller!.index;
|
||||
|
||||
testWidgets('a PDF uploaded from Add material shows in Files, titled by '
|
||||
'its name', (tester) async {
|
||||
await showApp(tester);
|
||||
|
|
@ -103,7 +101,7 @@ void main() {
|
|||
|
||||
expect(files.asked.single, ['pdf']);
|
||||
expect(find.text('Added "Grammar Guide".'), findsOneWidget);
|
||||
expect(shownTab(tester), 3);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.text('Grammar Guide'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
@ -163,7 +161,7 @@ void main() {
|
|||
await showApp(tester);
|
||||
});
|
||||
|
||||
expect(shownTab(tester), 3);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(find.textContaining('240 pages'), findsOneWidget);
|
||||
expect(find.text('25% read'), findsOneWidget);
|
||||
expect(find.text('6/day to reach page 120 in 10 days'), findsOneWidget);
|
||||
|
|
@ -188,7 +186,7 @@ void main() {
|
|||
expect(top('b'), lessThan(top('c')));
|
||||
});
|
||||
|
||||
testWidgets('Reorder beside the tabs shows the custom order, whatever the '
|
||||
testWidgets('Reorder beside the files shows the custom order, whatever the '
|
||||
'files were sorted by, and drags a file elsewhere in it, until '
|
||||
'Done', (tester) async {
|
||||
final pdfs = repository();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
|
||||
setUp(() {
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
addTearDown(cards.dispose);
|
||||
addTearDown(reviews.dispose);
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
);
|
||||
|
||||
/// The first activity read for [studysetIds].
|
||||
Future<StudysetActivityState> read(List<String> studysetIds) async {
|
||||
final cubit = StudysetActivityCubit(cards, reviews, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
return (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
}
|
||||
|
||||
test('nothing studied yet: no studying, and no time this week', () async {
|
||||
await card('japanese');
|
||||
|
||||
final activity = await read(['japanese']);
|
||||
|
||||
expect(activity.studied, isFalse);
|
||||
expect(activity.week, List.filled(7, Duration.zero));
|
||||
});
|
||||
|
||||
test('sums each day of the last seven, today last, across the studyset '
|
||||
'and what is inside it; a fresh start is no studying', () async {
|
||||
final now = DateTime(2026, 10, 12, 15);
|
||||
final own = await card('japanese');
|
||||
final inTopic = await card('grammar');
|
||||
final elsewhere = await card('french');
|
||||
Future<void> review(
|
||||
String cardId,
|
||||
DateTime at,
|
||||
int minutes, {
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) => reviews.record(
|
||||
cardId: cardId,
|
||||
rating: Rating.good,
|
||||
reviewedAt: at,
|
||||
duration: Duration(minutes: minutes),
|
||||
mode: mode,
|
||||
);
|
||||
await review(own, now, 2);
|
||||
await review(inTopic, now.subtract(const Duration(hours: 2)), 1);
|
||||
await review(own, now.subtract(const Duration(days: 1)), 3);
|
||||
await review(own, now.subtract(const Duration(days: 6)), 4);
|
||||
// A week ago and more, another studyset's, a fresh start: none counts.
|
||||
await review(own, now.subtract(const Duration(days: 7)), 5);
|
||||
await review(elsewhere, now, 6);
|
||||
await review(own, now, 7, mode: StudyMode.reset);
|
||||
|
||||
final activity = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => read(['japanese', 'grammar']),
|
||||
);
|
||||
|
||||
expect(activity.studied, isTrue);
|
||||
expect(activity.week, [
|
||||
const Duration(minutes: 4),
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
const Duration(minutes: 3),
|
||||
const Duration(minutes: 3),
|
||||
]);
|
||||
});
|
||||
|
||||
test('only a fresh start is no studying', () async {
|
||||
final own = await card('japanese');
|
||||
await reviews.record(
|
||||
cardId: own,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 1),
|
||||
mode: StudyMode.reset,
|
||||
);
|
||||
|
||||
expect((await read(['japanese'])).studied, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
|
|
@ -19,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
|
||||
|
|
@ -26,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -597,55 +602,59 @@ void main() {
|
|||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('every empty tab shows its icon, that there is nothing yet, '
|
||||
'and how to add some', (tester) async {
|
||||
testWidgets('every empty section shows its icon, that there is nothing '
|
||||
'yet, and how to add some', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
for (final (tab, icon, title, how) in [
|
||||
for (final (part, icon, title, how) in [
|
||||
(
|
||||
'Flashcards',
|
||||
'Material',
|
||||
LucideIcons.layers,
|
||||
'No flashcards yet',
|
||||
'"Add material → Create flashcards"',
|
||||
),
|
||||
(
|
||||
'Paths',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
(
|
||||
'Notes',
|
||||
'Material',
|
||||
LucideIcons.notebookPen,
|
||||
'No notes yet',
|
||||
'"Add material → Create a note"',
|
||||
),
|
||||
(
|
||||
'Files',
|
||||
'Material',
|
||||
LucideIcons.folder,
|
||||
'No files yet',
|
||||
'"Add material → Upload a PDF"',
|
||||
),
|
||||
(
|
||||
'Study plan',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
]) {
|
||||
await tester.tap(find.text(tab));
|
||||
await tester.pumpAndSettle();
|
||||
final message = find.byType(EmptyListMessage);
|
||||
expect(message, findsOneWidget, reason: tab);
|
||||
await openPart(tester, part);
|
||||
// The box that says so, around its icon, title and hint.
|
||||
final box = find.ancestor(
|
||||
of: find.text(title),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is EmptyListMessage ||
|
||||
(widget is DecoratedBox &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration as BoxDecoration).border != null),
|
||||
),
|
||||
);
|
||||
expect(box, findsOneWidget, reason: title);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.byIcon(icon)),
|
||||
find.descendant(of: box, matching: find.byIcon(icon)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.text(title)),
|
||||
find.descendant(of: box, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -868,7 +877,7 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
testWidgets('with two notes or more, Reorder beside the tabs lets them be '
|
||||
testWidgets('with two notes or more, Reorder beside them lets them be '
|
||||
'dragged into another order, until Done', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final notes = NoteRepository(noteGateway);
|
||||
|
|
@ -880,7 +889,7 @@ void main() {
|
|||
);
|
||||
}
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
// It opens on the notes, its only material.
|
||||
// It opens on the material, the notes among it.
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Reorder'));
|
||||
|
|
@ -1485,7 +1494,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('tapping the open tab again goes back to its top', (
|
||||
testWidgets('tapping the open part again goes back to its top', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
|
|
@ -1501,12 +1510,15 @@ void main() {
|
|||
// On screen, not merely built.
|
||||
final first = find.text('Note 1').hitTestable();
|
||||
expect(first, findsOneWidget);
|
||||
// The first three, until all show.
|
||||
expect(find.text('Note 4'), findsNothing);
|
||||
await tester.tap(find.text('Show all'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.drag(find.text('Note 3'), const Offset(0, -600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(first, findsNothing);
|
||||
await tester.tap(find.text('Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(first, findsOneWidget);
|
||||
});
|
||||
|
|
@ -1574,4 +1586,106 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// The parts the studyset screen offers, by their names.
|
||||
List<String> parts(WidgetTester tester) => [
|
||||
for (final part
|
||||
in tester
|
||||
.widget<StudysetPartSwitch>(find.byType(StudysetPartSwitch))
|
||||
.parts)
|
||||
studysetPartName(
|
||||
AppLocalizations.of(tester.element(find.byType(StudysetScreen))),
|
||||
part,
|
||||
),
|
||||
];
|
||||
|
||||
testWidgets('the parts are switched in the header: on a phone under the '
|
||||
"title, above the path; on a desktop at the end of the path's line", (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
width: 400,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
Rect switchRect() => tester.getRect(find.byType(StudysetPartSwitch));
|
||||
Rect pathRect() => tester.getRect(find.byType(StudysetBreadcrumb));
|
||||
expect(switchRect().bottom, lessThanOrEqualTo(pathRect().top));
|
||||
expect(switchRect().width, greaterThan(300));
|
||||
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.byType(StudysetPartSwitch),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The path's line stays as high as its steps.
|
||||
expect(pathRect().height, StudysetBreadcrumb.minTarget);
|
||||
expect(switchRect().right, greaterThan(pathRect().right - 16));
|
||||
});
|
||||
|
||||
testWidgets('Progress shows once a card in the studyset was studied: how '
|
||||
'much is learnt, what is due, and the study time this week', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final id = await card('japanese', '猫');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Study plan']);
|
||||
|
||||
await PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
).reviews.record(
|
||||
cardId: id,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 2),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Progress', 'Study plan']);
|
||||
|
||||
await openPart(tester, 'Progress');
|
||||
expect(find.text('Learned'), findsOneWidget);
|
||||
expect(find.text('0 of 1 card'), findsOneWidget);
|
||||
expect(find.text('To review'), findsOneWidget);
|
||||
expect(find.text('2 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('choosing another part ticks, as the navigation bar does; '
|
||||
'the one shown again does not', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final haptics = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
haptics.add(call.arguments);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
await openPart(tester, 'Material');
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(haptics, [
|
||||
'HapticFeedbackType.selectionClick',
|
||||
'HapticFeedbackType.selectionClick',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/widget/session_gate.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Pumps the whole app as a person starts it, on [dependencies] (usually
|
||||
|
|
@ -43,3 +45,19 @@ Future<void> openTab(WidgetTester tester, String label) async {
|
|||
await tester.tap(tab(label));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The part of the studyset screen shown.
|
||||
StudysetPart shownPart(WidgetTester tester) =>
|
||||
tester.widget<StudysetPartSwitch>(find.byType(StudysetPartSwitch)).current;
|
||||
|
||||
/// Shows the studyset screen's part [label] ("Material", "Study plan") and
|
||||
/// settles.
|
||||
Future<void> openPart(WidgetTester tester, String label) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetPartSwitch),
|
||||
matching: find.text(label),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue