feat(studyset): learning paths are material, the study plan refers to them

A learning path is material too, as its flashcards, notes and files are,
and the study plan only refers to it (decided 2026-10-13), as it will to
an exam's date. Material's tabs are now Paths, Flashcards, Notes and
Files, the paths first as the way through the rest, with Reorder beside
them; opened here for the first time, Material shows the first tab with
something in it. Study plan keeps how many paths are finished and Next
up, which names the next step of each path under way ("Step 3 of 6: が"),
or offers to start the first path not finished when none is, and lists
how far every path has come, its steps done in a bar, each opening the
path. The path list's state now knows each path's steps done and next
step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 06:38:33 +02:00
commit c3f7de9607
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 460 additions and 164 deletions

View file

@ -1738,6 +1738,10 @@
"planNextUp": "Als Nächstes", "planNextUp": "Als Nächstes",
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}", "planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
"planContinue": "„{title}“ fortsetzen", "planContinue": "„{title}“ fortsetzen",
"planStart": "„{title}“ beginnen",
"planStepOf": "Schritt {number} von {total}",
"planStepOfTitled": "Schritt {number} von {total}: {title}",
"planStepsDone": "{total, plural, =1{{done} von 1 Schritt} other{{done} von {total} Schritten}}",
"planNothing": "Gerade steht nichts an.", "planNothing": "Gerade steht nichts an.",
"planLearningPaths": "Lernpfade", "planLearningPaths": "Lernpfade",
"jellyfinTitle": "Jellyfin", "jellyfinTitle": "Jellyfin",

View file

@ -2049,6 +2049,14 @@
"@planReview": {"placeholders": {"count": {"type": "int"}}}, "@planReview": {"placeholders": {"count": {"type": "int"}}},
"planContinue": "Continue “{title}”", "planContinue": "Continue “{title}”",
"@planContinue": {"placeholders": {"title": {"type": "String"}}}, "@planContinue": {"placeholders": {"title": {"type": "String"}}},
"planStart": "Start “{title}”",
"@planStart": {"placeholders": {"title": {"type": "String"}}},
"planStepOf": "Step {number} of {total}",
"@planStepOf": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"planStepOfTitled": "Step {number} of {total}: {title}",
"@planStepOfTitled": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}, "title": {"type": "String"}}},
"planStepsDone": "{total, plural, =1{{done} of 1 step} other{{done} of {total} steps}}",
"@planStepsDone": {"placeholders": {"done": {"type": "int"}, "total": {"type": "int"}}},
"planNothing": "Nothing due right now.", "planNothing": "Nothing due right now.",
"planLearningPaths": "Learning paths", "planLearningPaths": "Learning paths",
"jellyfinTitle": "Jellyfin", "jellyfinTitle": "Jellyfin",

View file

@ -10269,6 +10269,30 @@ abstract class AppLocalizations {
/// **'Continue “{title}”'** /// **'Continue “{title}”'**
String planContinue(String title); String planContinue(String title);
/// No description provided for @planStart.
///
/// In en, this message translates to:
/// **'Start “{title}”'**
String planStart(String title);
/// No description provided for @planStepOf.
///
/// In en, this message translates to:
/// **'Step {number} of {total}'**
String planStepOf(int number, int total);
/// No description provided for @planStepOfTitled.
///
/// In en, this message translates to:
/// **'Step {number} of {total}: {title}'**
String planStepOfTitled(int number, int total, String title);
/// No description provided for @planStepsDone.
///
/// In en, this message translates to:
/// **'{total, plural, =1{{done} of 1 step} other{{done} of {total} steps}}'**
String planStepsDone(int done, int total);
/// No description provided for @planNothing. /// No description provided for @planNothing.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -6481,6 +6481,32 @@ class AppLocalizationsDe extends AppLocalizations {
return '„$title“ fortsetzen'; return '„$title“ fortsetzen';
} }
@override
String planStart(String title) {
return '„$title“ beginnen';
}
@override
String planStepOf(int number, int total) {
return 'Schritt $number von $total';
}
@override
String planStepOfTitled(int number, int total, String title) {
return 'Schritt $number von $total: $title';
}
@override
String planStepsDone(int done, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$done von $total Schritten',
one: '$done von 1 Schritt',
);
return '$_temp0';
}
@override @override
String get planNothing => 'Gerade steht nichts an.'; String get planNothing => 'Gerade steht nichts an.';

View file

@ -6425,6 +6425,32 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Continue “$title”'; return 'Continue “$title”';
} }
@override
String planStart(String title) {
return 'Start “$title”';
}
@override
String planStepOf(int number, int total) {
return 'Step $number of $total';
}
@override
String planStepOfTitled(int number, int total, String title) {
return 'Step $number of $total: $title';
}
@override
String planStepsDone(int done, int total) {
String _temp0 = intl.Intl.pluralLogic(
total,
locale: localeName,
other: '$done of $total steps',
one: '$done of 1 step',
);
return '$_temp0';
}
@override @override
String get planNothing => 'Nothing due right now.'; String get planNothing => 'Nothing due right now.';

View file

@ -12,8 +12,9 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_p
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
/// A studyset's learning paths in order, through every change, leaving out /// A studyset's learning paths in order, through every change, leaving out
/// those whose deletion can still be undone, and which of them are /// those whose deletion can still be undone, how far each has come (its
/// completed: every step done, and at least one step. Creates paths, /// steps done, and the next one), and which of them are completed: every
/// step done, and at least one step. Creates paths,
/// imports them from the import format, and starts a path's progress afresh /// imports them from the import format, and starts a path's progress afresh
/// with an undo. /// with an undo.
class PathListBloc( class PathListBloc(
@ -190,6 +191,7 @@ class PathListBloc(
final request = ++_completionRequests; final request = ++_completionRequests;
final completed = <String>{}; final completed = <String>{};
final started = <String>{}; final started = <String>{};
final stepsDone = <String, PathStepsDone>{};
try { try {
for (final path in _all ?? const <LearningPath>[]) { for (final path in _all ?? const <LearningPath>[]) {
final steps = await _paths.stepsOf(path.id); final steps = await _paths.stepsOf(path.id);
@ -203,6 +205,12 @@ class PathListBloc(
if (steps.any(done)) { if (steps.any(done)) {
started.add(path.id); started.add(path.id);
} }
final next = steps.indexWhere((step) => !done(step));
stepsDone[path.id] = (
done: steps.where(done).length,
total: steps.length,
next: next < 0 ? null : (step: steps[next], number: next + 1),
);
} }
} on Object catch (error) { } on Object catch (error) {
log('Working out completion failed', name: 'Paths', error: error); log('Working out completion failed', name: 'Paths', error: error);
@ -213,6 +221,7 @@ class PathListBloc(
state.copyWith( state.copyWith(
completed: completed, completed: completed,
started: started, started: started,
stepsDone: stepsDone,
completionKnown: true, completionKnown: true,
), ),
); );
@ -240,6 +249,15 @@ typedef PathFile = ({String name, String source});
/// the share of everything stored, from 0 to 1 (`done`). /// the share of everything stored, from 0 to 1 (`done`).
typedef PathImportProgress = ({String title, int path, int paths, double done}); typedef PathImportProgress = ({String title, int path, int paths, double done});
/// How far a path has come: how many of its steps are done, of how many,
/// and the first step not done (its place in the path, from 1); null once
/// every step is.
typedef PathStepsDone = ({
int done,
int total,
({LearningPathStep step, int number})? next,
});
/// A studyset's paths, as the list shows them. /// A studyset's paths, as the list shows them.
final class const PathListState({ final class const PathListState({
/// Whether the paths were read. /// Whether the paths were read.
@ -254,6 +272,9 @@ final class const PathListState({
/// The ids of the paths with a step done, completed or not. /// The ids of the paths with a step done, completed or not.
final Set<String> started = const {}, final Set<String> started = const {},
/// How far each path has come, by its id, once [completionKnown].
final Map<String, PathStepsDone> stepsDone = const {},
/// Whether [completed] was worked out at least once. /// Whether [completed] was worked out at least once.
final bool completionKnown = false, final bool completionKnown = false,
@ -288,6 +309,7 @@ final class const PathListState({
List<LearningPath>? paths, List<LearningPath>? paths,
Set<String>? completed, Set<String>? completed,
Set<String>? started, Set<String>? started,
Map<String, PathStepsDone>? stepsDone,
bool? completionKnown, bool? completionKnown,
String? created, String? created,
ImportResult? importResult, ImportResult? importResult,
@ -298,6 +320,7 @@ final class const PathListState({
paths: paths ?? this.paths, paths: paths ?? this.paths,
completed: completed ?? this.completed, completed: completed ?? this.completed,
started: started ?? this.started, started: started ?? this.started,
stepsDone: stepsDone ?? this.stepsDone,
completionKnown: completionKnown ?? this.completionKnown, completionKnown: completionKnown ?? this.completionKnown,
created: created ?? this.created, created: created ?? this.created,
importResult: importResult ?? this.importResult, importResult: importResult ?? this.importResult,
@ -313,6 +336,7 @@ final class const PathListState({
paths, paths,
completed, completed,
started, started,
stepsDone,
completionKnown, completionKnown,
created, created,
importResult, importResult,

View file

@ -1,12 +1,14 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
/// The kinds of a studyset's content, each in one of its parts. /// The kinds of a studyset's content, each in one of its parts: all of it
/// is material (decided 2026-10-13: a learning path is material too, which
/// the study plan refers to).
enum StudysetTab( enum StudysetTab(
/// The part that shows it. /// The part that shows it.
final StudysetPart part, final StudysetPart part,
) { ) {
/// Its learning paths. /// Its learning paths.
paths(StudysetPart.plan), paths(StudysetPart.material),
/// Its flashcards. /// Its flashcards.
flashcards(StudysetPart.material), flashcards(StudysetPart.material),
@ -21,14 +23,14 @@ enum StudysetTab(
/// The parts of a studyset's screen, in their order: what it holds, how its /// The parts of a studyset's screen, in their order: what it holds, how its
/// studying goes, and what to study next. /// studying goes, and what to study next.
enum StudysetPart() { enum StudysetPart() {
/// Its flashcards, notes and files. /// Its learning paths, flashcards, notes and files.
material, material,
/// How much is learnt, what is due, how long it was studied; once /// How much is learnt, what is due, how long it was studied; once
/// anything in it was studied. /// anything in it was studied.
progress, progress,
/// Its learning paths, and what to do next. /// What to do next, and how far each learning path has come.
plan, plan,
} }

View file

@ -18,9 +18,11 @@ import 'package:halkyon_learn_flutter/src/core/widget/count_badge.dart';
import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart'; import 'package:halkyon_learn_flutter/src/core/widget/empty_list_message.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart'; import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart'; import 'package:halkyon_learn_flutter/src/core/widget/sliver_centered.dart';
import 'package:halkyon_learn_flutter/src/core/widget/title_with_kicker.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.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/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_list.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/bloc/note_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart'; import 'package:halkyon_learn_flutter/src/feature/note/widget/note_list.dart';
@ -163,14 +165,15 @@ Widget _pullToSync(
const _foot = SliverToBoxAdapter(child: SizedBox(height: 96)); const _foot = SliverToBoxAdapter(child: SizedBox(height: 96));
/// A studyset's material in tabs (decided 2026-10-12, after a trial of /// A studyset's material in tabs (decided 2026-10-12, after a trial of
/// sections one below the other): Flashcards, Notes and Files, each with /// sections one below the other): Paths, Flashcards, Notes and Files
/// how many it holds, each list whole in its tab, its tools at the end of /// (paths first, as the way through the rest, decided 2026-10-13), each
/// the tabs' row (Browse and the tag filter for the flashcards, Reorder for /// with how many it holds, each list whole in its tab, its tools at the end
/// notes and files). Lists of hundreds of cards show whole at once, rather /// of the tabs' row (Browse and the tag filter for the flashcards, Reorder
/// than their first few behind Show all. The tab shown is kept as the /// for paths, notes and files). Lists of hundreds of cards show whole at
/// studyset's, on this device; opened here for the first time, it opens on /// once, rather than their first few behind Show all. The tab shown is kept
/// a tab with something in it, the flashcards if they have any, once the /// as the studyset's, on this device; opened here for the first time, it
/// lists are read. Material added shows in its tab. /// opens on the first tab with something in it, once the lists are read.
/// Material added shows in its tab.
class const MaterialPart({ class const MaterialPart({
required final String studysetId, required final String studysetId,
required final ScrollController scroll, required final ScrollController scroll,
@ -192,6 +195,7 @@ class _MaterialPartState()
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
/// The material's tabs, in order. /// The material's tabs, in order.
static const _tabs = [ static const _tabs = [
StudysetTab.paths,
StudysetTab.flashcards, StudysetTab.flashcards,
StudysetTab.notes, StudysetTab.notes,
StudysetTab.files, StudysetTab.files,
@ -217,6 +221,9 @@ class _MaterialPartState()
/// chosen, or the one with content, once the lists are read. /// chosen, or the one with content, once the lists are read.
late var _settled = _keptTab != null; late var _settled = _keptTab != null;
/// Whether the paths show handles to drag them.
var _reorderingPaths = false;
/// Whether the notes show handles to drag them. /// Whether the notes show handles to drag them.
var _reorderingNotes = false; var _reorderingNotes = false;
@ -284,21 +291,27 @@ class _MaterialPartState()
} }
} }
/// Opened here for the first time, the tab with something in it, once /// Opened here for the first time, the first tab with something in it,
/// the lists are read: the flashcards if they have any, else the notes, /// once the lists are read; with nothing anywhere, the flashcards, which
/// else the files, else the flashcards. /// most studysets begin with.
void _settleOnContent({ void _settleOnContent({
required int? paths,
required int? cards, required int? cards,
required int? notes, required int? notes,
required int? files, required int? files,
}) { }) {
if (_settled || cards == null || notes == null || files == null) { if (_settled ||
paths == null ||
cards == null ||
notes == null ||
files == null) {
return; return;
} }
_settled = true; _settled = true;
final tab = switch ((cards, notes, files)) { final tab = switch ((paths, cards, notes, files)) {
(0, > 0, _) => StudysetTab.notes, (> 0, _, _, _) => StudysetTab.paths,
(0, 0, > 0) => StudysetTab.files, (0, 0, > 0, _) => StudysetTab.notes,
(0, 0, 0, > 0) => StudysetTab.files,
_ => StudysetTab.flashcards, _ => StudysetTab.flashcards,
}; };
if (tab == _shown) { if (tab == _shown) {
@ -318,16 +331,23 @@ class _MaterialPartState()
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final paths = context.watch<PathListBloc>().state;
final cards = context.watch<CardListBloc>().state; final cards = context.watch<CardListBloc>().state;
final notes = context.watch<NoteListBloc>().state; final notes = context.watch<NoteListBloc>().state;
final files = context.watch<PdfListBloc>().state; final files = context.watch<PdfListBloc>().state;
final pathCount = paths.loaded ? paths.paths.length : null;
final cardCount = switch (cards) { final cardCount = switch (cards) {
CardListStateLoaded(:final cards) => cards.length, CardListStateLoaded(:final cards) => cards.length,
_ => null, _ => null,
}; };
final noteCount = notes.loaded ? notes.notes.length : null; final noteCount = notes.loaded ? notes.notes.length : null;
final fileCount = files.loaded ? files.rows.length : null; final fileCount = files.loaded ? files.rows.length : null;
_settleOnContent(cards: cardCount, notes: noteCount, files: fileCount); _settleOnContent(
paths: pathCount,
cards: cardCount,
notes: noteCount,
files: fileCount,
);
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0); const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
void reorderFiles() { void reorderFiles() {
if (!_reorderingFiles) { if (!_reorderingFiles) {
@ -343,6 +363,16 @@ class _MaterialPartState()
// The tab's tools from the widest to the narrowest, the first leaving // The tab's tools from the widest to the narrowest, the first leaving
// the tabs their whole width shown (CollapsingRow). // the tabs their whole width shown (CollapsingRow).
final tools = switch (_shown) { final tools = switch (_shown) {
StudysetTab.paths when paths.paths.length >= 2 || _reorderingPaths => [
for (final labelled in [true, false])
ReorderToggle(
reordering: _reorderingPaths,
labelled: labelled,
tooltip: l10n.studysetReorderPaths,
onToggle: () =>
setState(() => _reorderingPaths = !_reorderingPaths),
),
],
StudysetTab.flashcards => [ StudysetTab.flashcards => [
for (var folded = 0; folded <= CardListToolbar.mostFolded; folded++) for (var folded = 0; folded <= CardListToolbar.mostFolded; folded++)
CardListToolbar( CardListToolbar(
@ -373,6 +403,11 @@ class _MaterialPartState()
final body = !_settled final body = !_settled
? const _SliverSectionLoading() ? const _SliverSectionLoading()
: switch (_shown) { : switch (_shown) {
// The list says itself when it is loading or empty.
StudysetTab.paths => SliverPathList(
studysetId: widget.studysetId,
reordering: _reorderingPaths,
),
StudysetTab.flashcards => switch (cards) { StudysetTab.flashcards => switch (cards) {
CardListStateLoading() => const _SliverSectionLoading(), CardListStateLoading() => const _SliverSectionLoading(),
CardListStateLoaded(cards: []) => _SliverEmptySection( CardListStateLoaded(cards: []) => _SliverEmptySection(
@ -423,9 +458,16 @@ class _MaterialPartState()
} }
}, },
), ),
BlocListener<NoteListBloc, NoteListState>( BlocListener<PathListBloc, PathListState>(
listener: (context, state) { listener: (context, state) {
// Nothing left to reorder. // Nothing left to reorder.
if (_reorderingPaths && state.paths.length < 2) {
setState(() => _reorderingPaths = false);
}
},
),
BlocListener<NoteListBloc, NoteListState>(
listener: (context, state) {
if (_reorderingNotes && state.notes.length < 2) { if (_reorderingNotes && state.notes.length < 2) {
setState(() => _reorderingNotes = false); setState(() => _reorderingNotes = false);
} }
@ -453,12 +495,14 @@ class _MaterialPartState()
isScrollable: true, isScrollable: true,
tabAlignment: TabAlignment.start, tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label, indicatorSize: TabBarIndicatorSize.label,
// Closer than Material's 16 a side, so the three tabs with // Closer than Material's 16 a side, so the tabs with their
// their counts fit a narrow phone beside the menu. // counts fit a narrow phone beside the menu as far as they
// can; the rest scrolls into view.
labelPadding: const EdgeInsets.symmetric(horizontal: 12), labelPadding: const EdgeInsets.symmetric(horizontal: 12),
// The line below runs the whole width instead. // The line below runs the whole width instead.
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
tabs: [ tabs: [
_TabLabel(l10n.studysetTabPaths, pathCount),
_TabLabel(l10n.studysetTabFlashcards, cardCount), _TabLabel(l10n.studysetTabFlashcards, cardCount),
_TabLabel(l10n.studysetTabNotes, noteCount), _TabLabel(l10n.studysetTabNotes, noteCount),
_TabLabel(l10n.studysetTabFiles, fileCount), _TabLabel(l10n.studysetTabFiles, fileCount),
@ -755,25 +799,17 @@ class const _Sparkline({
oldDelegate.color != color || !listEquals(oldDelegate.values, values); oldDelegate.color != color || !listEquals(oldDelegate.values, values);
} }
/// The studyset [studysetId]'s plan: how many of its learning paths are /// The studyset [studysetId]'s plan (decided 2026-10-13): how many of its
/// finished, what to do next (study the cards due, go on with the first /// learning paths are finished, what to do next (study the cards due, the
/// path not finished), then the paths. /// next step of each path under way, or of the first path not finished
/// when none is), and how far each path has come, each opening the path.
/// The paths themselves, to add, reorder and manage, are material; the
/// plan only refers to them, as it will to an exam's date.
class const PlanPart({ class const PlanPart({
required final String studysetId, required final String studysetId,
required final ScrollController scroll, required final ScrollController scroll,
/// Whether Reorder says what it does.
required final bool labelled,
super.key, super.key,
}) extends StatefulWidget { }) extends StatelessWidget {
@override
State<PlanPart> createState() => _PlanPartState();
}
class _PlanPartState() extends State<PlanPart> {
/// Whether the paths show handles to drag them.
var _reordering = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -783,7 +819,7 @@ class _PlanPartState() extends State<PlanPart> {
final due = context.select<StudysetListBloc, int>( final due = context.select<StudysetListBloc, int>(
(bloc) => switch (bloc.state) { (bloc) => switch (bloc.state) {
StudysetListStateLoaded(:final progress) => StudysetListStateLoaded(:final progress) =>
progress[widget.studysetId]?.due ?? 0, progress[studysetId]?.due ?? 0,
_ => 0, _ => 0,
}, },
); );
@ -791,134 +827,188 @@ class _PlanPartState() extends State<PlanPart> {
final done = paths.paths final done = paths.paths
.where((path) => paths.completed.contains(path.id)) .where((path) => paths.completed.contains(path.id))
.length; .length;
final next = paths.completionKnown final open = paths.completionKnown
? paths.paths ? [
.where((path) => !paths.completed.contains(path.id)) for (final path in paths.paths)
.firstOrNull if (!paths.completed.contains(path.id)) path,
: null; ]
: const <LearningPath>[];
final underWay = [
for (final path in open)
if (paths.started.contains(path.id)) path,
];
void openPath(LearningPath path) =>
context.push(RoutePaths.path(studysetId, path.id));
String? nextStep(LearningPath path) => switch (paths.stepsDone[path.id]) {
(done: _, :final total, next: (:final step, :final number)?) =>
step.content.title.trim().isEmpty
? l10n.planStepOf(number, total)
: l10n.planStepOfTitled(number, total, step.content.title),
_ => null,
};
final tasks = [ final tasks = [
if (due > 0) if (due > 0)
_Task( _Task(
icon: LucideIcons.layers, icon: LucideIcons.layers,
title: l10n.planReview(due), title: l10n.planReview(due),
onTap: () => startStudying(context, widget.studysetId), onTap: () => startStudying(context, studysetId),
), ),
if (next != null) for (final path in underWay.isEmpty ? open.take(1) : underWay)
_Task( _Task(
icon: LucideIcons.route, icon: LucideIcons.route,
title: l10n.planContinue(next.content.title), title: paths.started.contains(path.id)
onTap: () => ? l10n.planContinue(path.content.title)
context.push(RoutePaths.path(widget.studysetId, next.id)), : l10n.planStart(path.content.title),
subtitle: nextStep(path),
onTap: () => openPath(path),
), ),
]; ];
return BlocListener<PathListBloc, PathListState>( return _pullToSync(context, scroll, [
listener: (context, state) { SliverToBoxAdapter(
// Nothing left to reorder. child: Padding(
if (_reordering && state.paths.length < 2) { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
setState(() => _reordering = false); child: Row(
} children: [
}, SizedBox.square(
child: _pullToSync(context, widget.scroll, [ dimension: 56,
SliverToBoxAdapter( child: Stack(
child: Padding( fit: StackFit.expand,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), children: [
child: Row( CircularProgressIndicator(
children: [ value: total == 0 ? 0 : done / total,
SizedBox.square( strokeWidth: 6,
dimension: 56, backgroundColor: colors.surfaceContainerHighest,
child: Stack( ),
fit: StackFit.expand, Center(
children: [ child: Text(
CircularProgressIndicator( '${total == 0 ? 0 : (done * 100 / total).round()}%',
value: total == 0 ? 0 : done / total, style: theme.textTheme.labelMedium?.copyWith(
strokeWidth: 6, fontWeight: FontWeight.w700,
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( const SizedBox(width: 16),
child: Text( Expanded(
total == 0 child: Text(
? l10n.planNoPaths total == 0
: l10n.planPathsDone(done, total), ? l10n.planNoPaths
style: theme.textTheme.titleMedium, : 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),
),
if (total > 0) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(l10n.planNextUp, style: theme.textTheme.titleMedium), child: Text(
l10n.planLearningPaths,
style: theme.textTheme.titleMedium,
),
), ),
), ),
if (tasks.isEmpty) SliverList.separated(
SliverToBoxAdapter( itemCount: total,
child: Padding( itemBuilder: (context, index) {
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), final path = paths.paths[index];
child: Text( return _PathProgress(
l10n.planNothing, key: ValueKey(path.id),
style: theme.textTheme.bodyMedium?.copyWith( path: path,
steps: paths.stepsDone[path.id],
completed: paths.completed.contains(path.id),
onTap: () => openPath(path),
);
},
separatorBuilder: (context, _) => const Divider(height: 1),
),
],
_foot,
]);
}
}
/// How far a path has come, in the plan: its title, a bar of its steps
/// done and how many of how many; a check once completed. A tap opens it.
class const _PathProgress({
required final LearningPath path,
/// Its steps done; null until worked out.
required final PathStepsDone? steps,
required final bool completed,
required final VoidCallback onTap,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return ListTile(
leading: Icon(
completed ? LucideIcons.circleCheck : LucideIcons.route,
color: completed ? colors.primary : null,
),
title: TitleWithKicker(
title: path.content.title,
kicker: path.content.secondaryTitle,
titleMaxLines: 1,
),
subtitle: switch (steps) {
(:final done, :final total, next: _) when total > 0 => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: done / total,
minHeight: 4,
borderRadius: BorderRadius.circular(2),
backgroundColor: colors.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Text(
l10n.planStepsDone(done, total),
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant, 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: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
l10n.planLearningPaths,
style: theme.textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
CountBadge(count: total),
],
),
),
if (total >= 2 || _reordering)
ReorderToggle(
reordering: _reordering,
labelled: widget.labelled,
tooltip: l10n.studysetReorderPaths,
onToggle: () => setState(() => _reordering = !_reordering),
),
],
),
), ),
), ),
SliverPathList( _ => null,
studysetId: widget.studysetId, },
reordering: _reordering, trailing: const Icon(LucideIcons.chevronRight),
padding: const EdgeInsets.only(top: 4), onTap: onTap,
),
_foot,
]),
); );
} }
} }
@ -928,6 +1018,9 @@ class const _Task({
required final IconData icon, required final IconData icon,
required final String title, required final String title,
required final VoidCallback onTap, required final VoidCallback onTap,
/// What it is, more closely; null without.
final String? subtitle,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -942,6 +1035,14 @@ class const _Task({
), ),
leading: Icon(icon, color: colors.primary), leading: Icon(icon, color: colors.primary),
title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis), title: Text(title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: switch (subtitle) {
final subtitle? => Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
null => null,
},
trailing: const Icon(LucideIcons.chevronRight), trailing: const Icon(LucideIcons.chevronRight),
onTap: onTap, onTap: onTap,
), ),

View file

@ -706,11 +706,11 @@ typedef _PartsView = ({
}); });
/// The studyset's content in three parts, chosen with the switch in the /// The studyset's content in three parts, chosen with the switch in the
/// header: Material (its flashcards, notes and files, a section each), /// header: Material (its learning paths, flashcards, notes and files, a tab
/// Progress (how much is learnt, what is due, the study time this week; /// each), Progress (how much is learnt, what is due, the study time this
/// always, nothing yet where nothing was studied, decided 2026-10-12, so the /// week; always, nothing yet where nothing was studied, decided 2026-10-12,
/// switch never changes as reviews are read) and Study plan (its learning /// so the switch never changes as reviews are read) and Study plan (what to
/// paths, and what to do next). [builder] gets which shows. /// do next, and how far each path has come). [builder] gets which shows.
/// ///
/// It opens on the part 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 /// decided 2026-10-11). Opened here for the first time, it opens on the
@ -893,7 +893,6 @@ class const _Content({
PlanPart( PlanPart(
studysetId: studysetId, studysetId: studysetId,
scroll: parts.scrolls[StudysetPart.plan]!, scroll: parts.scrolls[StudysetPart.plan]!,
labelled: labelled,
), ),
], ],
); );

View file

@ -10,10 +10,13 @@ import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_sett
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart'; import 'package:halkyon_learn_flutter/src/core/widget/section_card.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/confetti_burst.dart';
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/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.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/bloc/content_tab_cubit.dart';
@ -150,8 +153,9 @@ void main() {
expect(tabs.kept, {'japanese': 'plan'}); expect(tabs.kept, {'japanese': 'plan'});
await openPart(tester, 'Material'); await openPart(tester, 'Material');
// The material's tab, which says the part too. // The material's tab, which says the part too: the paths, the first
expect(tabs.kept, {'japanese': 'flashcards'}); // with something in it.
expect(tabs.kept, {'japanese': 'paths'});
await tester.tap(find.widgetWithText(Tab, 'Notes')); await tester.tap(find.widgetWithText(Tab, 'Notes'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tabs.kept, {'japanese': 'notes'}); expect(tabs.kept, {'japanese': 'notes'});
@ -159,6 +163,71 @@ void main() {
expect(tabs.kept, {'japanese': 'plan'}); expect(tabs.kept, {'japanese': 'plan'});
}); });
testWidgets('the study plan names the next step of each path under way, '
'and how far every path has come; a tap opens the path', (
tester,
) async {
final paths = repository();
final particles = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
final read = await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'Read'),
);
await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'は and が'),
);
await paths.createStep(
particles,
StepKind.info,
step(StepKind.info, 'More'),
);
final verbs = await paths.createPath(
'japanese',
LearningPathContent.titled('Verbs'),
);
await paths.createStep(verbs, StepKind.info, step(StepKind.info, 'Groups'));
final progress = PathProgressRepository(answers);
addTearDown(progress.dispose);
await progress.record(read.stepId, AnswerOutcome.continued);
await showApp(tester, RoutePaths.studyset('japanese'));
expect(shownPart(tester), StudysetPart.plan);
// The path under way, not the one not begun yet.
expect(find.text('Continue “Particles”'), findsOneWidget);
expect(find.text('Step 2 of 3: は and が'), findsOneWidget);
expect(find.text('Start “Verbs”'), findsNothing);
expect(find.text('1 of 3 steps'), findsOneWidget);
expect(find.text('0 of 1 step'), findsOneWidget);
// Managing the paths is the material's.
expect(find.byTooltip('Path options'), findsNothing);
await tester.tap(find.text('Verbs'));
await tester.pumpAndSettle();
expect(find.byType(PathRunnerScreen), findsOneWidget);
});
testWidgets('with no path under way, the study plan offers to start the '
'first one not finished', (tester) async {
final paths = repository();
final id = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('Start “Particles”'), findsOneWidget);
expect(find.text('Step 1 of 1: Read'), findsOneWidget);
});
testWidgets('a studyset with only notes opens on them', (tester) async { testWidgets('a studyset with only notes opens on them', (tester) async {
final notes = NoteRepository(noteGateway); final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose); addTearDown(notes.dispose);
@ -198,8 +267,11 @@ void main() {
await showApp(tester, RoutePaths.studyset('japanese')); await showApp(tester, RoutePaths.studyset('japanese'));
expect(find.text('No flashcards yet'), findsOneWidget); expect(find.text('No flashcards yet'), findsOneWidget);
await openPart(tester, 'Study plan'); await tester.tap(find.widgetWithText(Tab, 'Paths'));
await tester.pumpAndSettle();
expect(find.text('No paths yet'), findsOneWidget); expect(find.text('No paths yet'), findsOneWidget);
await openPart(tester, 'Study plan');
expect(find.text('No learning paths yet'), findsOneWidget);
}); });
testWidgets('Create a path asks for a title, then opens the path to write; ' testWidgets('Create a path asks for a title, then opens the path to write; '
@ -295,7 +367,7 @@ void main() {
); );
expect(find.textContaining('1 block was left out'), findsOneWidget); expect(find.textContaining('1 block was left out'), findsOneWidget);
// The paths show by themselves. // The paths show by themselves.
expect(shownPart(tester), StudysetPart.plan); expect(shownPart(tester), StudysetPart.material);
expect(find.text('Particles'), findsOneWidget); expect(find.text('Particles'), findsOneWidget);
}); });
@ -333,8 +405,8 @@ void main() {
); );
}); });
testWidgets('an import shows the study plan at once, and how far it has ' testWidgets('an import shows the paths at once, and how far it has come '
'come above the paths it adds', (tester) async { 'above those it adds', (tester) async {
Uint8List path(String title) => utf8.encode( Uint8List path(String title) => utf8.encode(
'{"path": {"title": "$title", "steps": [' '{"path": {"title": "$title", "steps": ['
'{"title": "Read", "type": "info"}]}}', '{"title": "Read", "type": "info"}]}}',
@ -361,7 +433,7 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
expect(shownPart(tester), StudysetPart.plan); expect(shownPart(tester), StudysetPart.material);
expect( expect(
find.text('Importing path 1 of 2: “State-of-being”…'), find.text('Importing path 1 of 2: “State-of-being”…'),
findsOneWidget, findsOneWidget,
@ -587,6 +659,8 @@ void main() {
); );
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read')); await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese')); await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
await tester.tap(find.byTooltip('Path options')); await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -753,6 +827,8 @@ void main() {
); );
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read')); await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese')); await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
await tester.tap(find.byTooltip('Path options')); await tester.tap(find.byTooltip('Path options'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@ -784,6 +860,8 @@ void main() {
); );
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read')); await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
await showApp(tester, RoutePaths.studyset('japanese')); await showApp(tester, RoutePaths.studyset('japanese'));
// The paths are material; the plan only shows how far they came.
await openPart(tester, 'Material');
expect( expect(
tester.getRect(find.text('LESSON 1')).bottom, tester.getRect(find.text('LESSON 1')).bottom,
lessThanOrEqualTo(tester.getRect(find.text('Particles')).top), lessThanOrEqualTo(tester.getRect(find.text('Particles')).top),

View file

@ -640,7 +640,10 @@ void main() {
StepContent.empty(StepKind.info), StepContent.empty(StepKind.info),
); );
} }
await openFromLibrary(tester, 'Japanese'); // Wide enough for Reorder's word beside four tabs.
await openFromLibrary(tester, 'Japanese', width: 1600);
// The paths are material, on its first tab.
await openPart(tester, 'Material');
expect(find.byTooltip('Drag to reorder'), findsNothing); expect(find.byTooltip('Drag to reorder'), findsNothing);
await tester.tap(find.text('Reorder')); await tester.tap(find.text('Reorder'));
@ -657,6 +660,13 @@ void main() {
await openFromLibrary(tester, 'Japanese'); await openFromLibrary(tester, 'Japanese');
for (final (part, tab, icon, title, how) in [ for (final (part, tab, icon, title, how) in [
(
'Material',
'Paths',
LucideIcons.route,
'No paths yet',
'"Add material → Create a path"',
),
( (
'Material', 'Material',
'Flashcards', 'Flashcards',
@ -678,13 +688,6 @@ void main() {
'No files yet', 'No files yet',
'"Add material → Upload a PDF"', '"Add material → Upload a PDF"',
), ),
(
'Study plan',
null,
LucideIcons.route,
'No paths yet',
'"Add material → Create a path"',
),
]) { ]) {
await openPart(tester, part); await openPart(tester, part);
if (tab != null) { if (tab != null) {
@ -945,7 +948,8 @@ void main() {
NoteContent(title: title, body: RichContent.empty()), NoteContent(title: title, body: RichContent.empty()),
); );
} }
await openFromLibrary(tester, 'Japanese'); // Wide enough for Reorder's word beside four tabs.
await openFromLibrary(tester, 'Japanese', width: 1600);
// It opens on the material, the notes among it. // It opens on the material, the notes among it.
expect(find.byTooltip('Drag to reorder'), findsNothing); expect(find.byTooltip('Drag to reorder'), findsNothing);