feat(studyset): Material, Progress and Study plan in place of the tabs
All checks were successful
CI / check (push) Successful in 15m14s
CI / deploy (push) Successful in 1m17s

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:
Tizian 「ティツィアーン」 2026-10-12 13:48:11 +02:00
commit 246c9dc143
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 1992 additions and 482 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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`).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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