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:
parent
744a068c97
commit
c3f7de9607
11 changed files with 460 additions and 164 deletions
|
|
@ -1738,6 +1738,10 @@
|
|||
"planNextUp": "Als Nächstes",
|
||||
"planReview": "{count, plural, =1{1 Karteikarte lernen} other{{count} Karteikarten lernen}}",
|
||||
"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.",
|
||||
"planLearningPaths": "Lernpfade",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
|
|
|
|||
|
|
@ -2049,6 +2049,14 @@
|
|||
"@planReview": {"placeholders": {"count": {"type": "int"}}},
|
||||
"planContinue": "Continue “{title}”",
|
||||
"@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.",
|
||||
"planLearningPaths": "Learning paths",
|
||||
"jellyfinTitle": "Jellyfin",
|
||||
|
|
|
|||
|
|
@ -10269,6 +10269,30 @@ abstract class AppLocalizations {
|
|||
/// **'Continue “{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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -6481,6 +6481,32 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
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
|
||||
String get planNothing => 'Gerade steht nichts an.';
|
||||
|
||||
|
|
|
|||
|
|
@ -6425,6 +6425,32 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String get planNothing => 'Nothing due right now.';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/// A studyset's learning paths in order, through every change, leaving out
|
||||
/// those whose deletion can still be undone, and which of them are
|
||||
/// completed: every step done, and at least one step. Creates paths,
|
||||
/// those whose deletion can still be undone, how far each has come (its
|
||||
/// 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
|
||||
/// with an undo.
|
||||
class PathListBloc(
|
||||
|
|
@ -190,6 +191,7 @@ class PathListBloc(
|
|||
final request = ++_completionRequests;
|
||||
final completed = <String>{};
|
||||
final started = <String>{};
|
||||
final stepsDone = <String, PathStepsDone>{};
|
||||
try {
|
||||
for (final path in _all ?? const <LearningPath>[]) {
|
||||
final steps = await _paths.stepsOf(path.id);
|
||||
|
|
@ -203,6 +205,12 @@ class PathListBloc(
|
|||
if (steps.any(done)) {
|
||||
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) {
|
||||
log('Working out completion failed', name: 'Paths', error: error);
|
||||
|
|
@ -213,6 +221,7 @@ class PathListBloc(
|
|||
state.copyWith(
|
||||
completed: completed,
|
||||
started: started,
|
||||
stepsDone: stepsDone,
|
||||
completionKnown: true,
|
||||
),
|
||||
);
|
||||
|
|
@ -240,6 +249,15 @@ typedef PathFile = ({String name, String source});
|
|||
/// the share of everything stored, from 0 to 1 (`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.
|
||||
final class const PathListState({
|
||||
/// 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.
|
||||
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.
|
||||
final bool completionKnown = false,
|
||||
|
||||
|
|
@ -288,6 +309,7 @@ final class const PathListState({
|
|||
List<LearningPath>? paths,
|
||||
Set<String>? completed,
|
||||
Set<String>? started,
|
||||
Map<String, PathStepsDone>? stepsDone,
|
||||
bool? completionKnown,
|
||||
String? created,
|
||||
ImportResult? importResult,
|
||||
|
|
@ -298,6 +320,7 @@ final class const PathListState({
|
|||
paths: paths ?? this.paths,
|
||||
completed: completed ?? this.completed,
|
||||
started: started ?? this.started,
|
||||
stepsDone: stepsDone ?? this.stepsDone,
|
||||
completionKnown: completionKnown ?? this.completionKnown,
|
||||
created: created ?? this.created,
|
||||
importResult: importResult ?? this.importResult,
|
||||
|
|
@ -313,6 +336,7 @@ final class const PathListState({
|
|||
paths,
|
||||
completed,
|
||||
started,
|
||||
stepsDone,
|
||||
completionKnown,
|
||||
created,
|
||||
importResult,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
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(
|
||||
/// The part that shows it.
|
||||
final StudysetPart part,
|
||||
) {
|
||||
/// Its learning paths.
|
||||
paths(StudysetPart.plan),
|
||||
paths(StudysetPart.material),
|
||||
|
||||
/// Its flashcards.
|
||||
flashcards(StudysetPart.material),
|
||||
|
|
@ -21,14 +23,14 @@ enum StudysetTab(
|
|||
/// 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.
|
||||
/// Its learning paths, 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.
|
||||
/// What to do next, and how far each learning path has come.
|
||||
plan,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/reorder_toggle.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/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/model/learning_path.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';
|
||||
|
|
@ -163,14 +165,15 @@ Widget _pullToSync(
|
|||
const _foot = SliverToBoxAdapter(child: SizedBox(height: 96));
|
||||
|
||||
/// 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
|
||||
/// how many it holds, each list whole in its tab, its tools at the end of
|
||||
/// the tabs' row (Browse and the tag filter for the flashcards, Reorder for
|
||||
/// notes and files). Lists of hundreds of cards show whole at once, rather
|
||||
/// than their first few behind Show all. The tab shown is kept as the
|
||||
/// studyset's, on this device; opened here for the first time, it opens on
|
||||
/// a tab with something in it, the flashcards if they have any, once the
|
||||
/// lists are read. Material added shows in its tab.
|
||||
/// sections one below the other): Paths, Flashcards, Notes and Files
|
||||
/// (paths first, as the way through the rest, decided 2026-10-13), each
|
||||
/// with how many it holds, each list whole in its tab, its tools at the end
|
||||
/// of the tabs' row (Browse and the tag filter for the flashcards, Reorder
|
||||
/// for paths, notes and files). Lists of hundreds of cards show whole at
|
||||
/// once, rather than their first few behind Show all. The tab shown is kept
|
||||
/// as the studyset's, on this device; opened here for the first time, it
|
||||
/// opens on the first tab with something in it, once the lists are read.
|
||||
/// Material added shows in its tab.
|
||||
class const MaterialPart({
|
||||
required final String studysetId,
|
||||
required final ScrollController scroll,
|
||||
|
|
@ -192,6 +195,7 @@ class _MaterialPartState()
|
|||
with SingleTickerProviderStateMixin {
|
||||
/// The material's tabs, in order.
|
||||
static const _tabs = [
|
||||
StudysetTab.paths,
|
||||
StudysetTab.flashcards,
|
||||
StudysetTab.notes,
|
||||
StudysetTab.files,
|
||||
|
|
@ -217,6 +221,9 @@ class _MaterialPartState()
|
|||
/// chosen, or the one with content, once the lists are read.
|
||||
late var _settled = _keptTab != null;
|
||||
|
||||
/// Whether the paths show handles to drag them.
|
||||
var _reorderingPaths = false;
|
||||
|
||||
/// Whether the notes show handles to drag them.
|
||||
var _reorderingNotes = false;
|
||||
|
||||
|
|
@ -284,21 +291,27 @@ class _MaterialPartState()
|
|||
}
|
||||
}
|
||||
|
||||
/// Opened here for the first time, the tab with something in it, once
|
||||
/// the lists are read: the flashcards if they have any, else the notes,
|
||||
/// else the files, else the flashcards.
|
||||
/// Opened here for the first time, the first tab with something in it,
|
||||
/// once the lists are read; with nothing anywhere, the flashcards, which
|
||||
/// most studysets begin with.
|
||||
void _settleOnContent({
|
||||
required int? paths,
|
||||
required int? cards,
|
||||
required int? notes,
|
||||
required int? files,
|
||||
}) {
|
||||
if (_settled || cards == null || notes == null || files == null) {
|
||||
if (_settled ||
|
||||
paths == null ||
|
||||
cards == null ||
|
||||
notes == null ||
|
||||
files == null) {
|
||||
return;
|
||||
}
|
||||
_settled = true;
|
||||
final tab = switch ((cards, notes, files)) {
|
||||
(0, > 0, _) => StudysetTab.notes,
|
||||
(0, 0, > 0) => StudysetTab.files,
|
||||
final tab = switch ((paths, cards, notes, files)) {
|
||||
(> 0, _, _, _) => StudysetTab.paths,
|
||||
(0, 0, > 0, _) => StudysetTab.notes,
|
||||
(0, 0, 0, > 0) => StudysetTab.files,
|
||||
_ => StudysetTab.flashcards,
|
||||
};
|
||||
if (tab == _shown) {
|
||||
|
|
@ -318,16 +331,23 @@ class _MaterialPartState()
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final paths = context.watch<PathListBloc>().state;
|
||||
final cards = context.watch<CardListBloc>().state;
|
||||
final notes = context.watch<NoteListBloc>().state;
|
||||
final files = context.watch<PdfListBloc>().state;
|
||||
final pathCount = paths.loaded ? paths.paths.length : null;
|
||||
final cardCount = switch (cards) {
|
||||
CardListStateLoaded(:final cards) => cards.length,
|
||||
_ => null,
|
||||
};
|
||||
final noteCount = notes.loaded ? notes.notes.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);
|
||||
void reorderFiles() {
|
||||
if (!_reorderingFiles) {
|
||||
|
|
@ -343,6 +363,16 @@ class _MaterialPartState()
|
|||
// The tab's tools from the widest to the narrowest, the first leaving
|
||||
// the tabs their whole width shown (CollapsingRow).
|
||||
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 => [
|
||||
for (var folded = 0; folded <= CardListToolbar.mostFolded; folded++)
|
||||
CardListToolbar(
|
||||
|
|
@ -373,6 +403,11 @@ class _MaterialPartState()
|
|||
final body = !_settled
|
||||
? const _SliverSectionLoading()
|
||||
: switch (_shown) {
|
||||
// The list says itself when it is loading or empty.
|
||||
StudysetTab.paths => SliverPathList(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reorderingPaths,
|
||||
),
|
||||
StudysetTab.flashcards => switch (cards) {
|
||||
CardListStateLoading() => const _SliverSectionLoading(),
|
||||
CardListStateLoaded(cards: []) => _SliverEmptySection(
|
||||
|
|
@ -423,9 +458,16 @@ class _MaterialPartState()
|
|||
}
|
||||
},
|
||||
),
|
||||
BlocListener<NoteListBloc, NoteListState>(
|
||||
BlocListener<PathListBloc, PathListState>(
|
||||
listener: (context, state) {
|
||||
// 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) {
|
||||
setState(() => _reorderingNotes = false);
|
||||
}
|
||||
|
|
@ -453,12 +495,14 @@ class _MaterialPartState()
|
|||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
// Closer than Material's 16 a side, so the three tabs with
|
||||
// their counts fit a narrow phone beside the menu.
|
||||
// Closer than Material's 16 a side, so the tabs with their
|
||||
// counts fit a narrow phone beside the menu as far as they
|
||||
// can; the rest scrolls into view.
|
||||
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
// The line below runs the whole width instead.
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
_TabLabel(l10n.studysetTabPaths, pathCount),
|
||||
_TabLabel(l10n.studysetTabFlashcards, cardCount),
|
||||
_TabLabel(l10n.studysetTabNotes, noteCount),
|
||||
_TabLabel(l10n.studysetTabFiles, fileCount),
|
||||
|
|
@ -755,25 +799,17 @@ class const _Sparkline({
|
|||
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.
|
||||
/// The studyset [studysetId]'s plan (decided 2026-10-13): how many of its
|
||||
/// learning paths are finished, what to do next (study the cards due, the
|
||||
/// 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({
|
||||
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;
|
||||
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -783,7 +819,7 @@ class _PlanPartState() extends State<PlanPart> {
|
|||
final due = context.select<StudysetListBloc, int>(
|
||||
(bloc) => switch (bloc.state) {
|
||||
StudysetListStateLoaded(:final progress) =>
|
||||
progress[widget.studysetId]?.due ?? 0,
|
||||
progress[studysetId]?.due ?? 0,
|
||||
_ => 0,
|
||||
},
|
||||
);
|
||||
|
|
@ -791,134 +827,188 @@ class _PlanPartState() extends State<PlanPart> {
|
|||
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 open = paths.completionKnown
|
||||
? [
|
||||
for (final path in paths.paths)
|
||||
if (!paths.completed.contains(path.id)) path,
|
||||
]
|
||||
: 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 = [
|
||||
if (due > 0)
|
||||
_Task(
|
||||
icon: LucideIcons.layers,
|
||||
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(
|
||||
icon: LucideIcons.route,
|
||||
title: l10n.planContinue(next.content.title),
|
||||
onTap: () =>
|
||||
context.push(RoutePaths.path(widget.studysetId, next.id)),
|
||||
title: paths.started.contains(path.id)
|
||||
? l10n.planContinue(path.content.title)
|
||||
: l10n.planStart(path.content.title),
|
||||
subtitle: nextStep(path),
|
||||
onTap: () => openPath(path),
|
||||
),
|
||||
];
|
||||
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,
|
||||
),
|
||||
return _pullToSync(context, 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,
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
if (total > 0) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
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)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
|
||||
child: Text(
|
||||
l10n.planNothing,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
SliverList.separated(
|
||||
itemCount: total,
|
||||
itemBuilder: (context, index) {
|
||||
final path = paths.paths[index];
|
||||
return _PathProgress(
|
||||
key: ValueKey(path.id),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
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(
|
||||
studysetId: widget.studysetId,
|
||||
reordering: _reordering,
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
),
|
||||
_foot,
|
||||
]),
|
||||
_ => null,
|
||||
},
|
||||
trailing: const Icon(LucideIcons.chevronRight),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -928,6 +1018,9 @@ class const _Task({
|
|||
required final IconData icon,
|
||||
required final String title,
|
||||
required final VoidCallback onTap,
|
||||
|
||||
/// What it is, more closely; null without.
|
||||
final String? subtitle,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -942,6 +1035,14 @@ class const _Task({
|
|||
),
|
||||
leading: Icon(icon, color: colors.primary),
|
||||
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),
|
||||
onTap: onTap,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -706,11 +706,11 @@ typedef _PartsView = ({
|
|||
});
|
||||
|
||||
/// 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;
|
||||
/// always, nothing yet where nothing was studied, decided 2026-10-12, so the
|
||||
/// switch never changes as reviews are read) and Study plan (its learning
|
||||
/// paths, and what to do next). [builder] gets which shows.
|
||||
/// header: Material (its learning paths, flashcards, notes and files, a tab
|
||||
/// each), Progress (how much is learnt, what is due, the study time this
|
||||
/// week; always, nothing yet where nothing was studied, decided 2026-10-12,
|
||||
/// so the switch never changes as reviews are read) and Study plan (what to
|
||||
/// 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],
|
||||
/// decided 2026-10-11). Opened here for the first time, it opens on the
|
||||
|
|
@ -893,7 +893,6 @@ class const _Content({
|
|||
PlanPart(
|
||||
studysetId: studysetId,
|
||||
scroll: parts.scrolls[StudysetPart.plan]!,
|
||||
labelled: labelled,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/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/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/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/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/model/note_content.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'});
|
||||
|
||||
await openPart(tester, 'Material');
|
||||
// The material's tab, which says the part too.
|
||||
expect(tabs.kept, {'japanese': 'flashcards'});
|
||||
// The material's tab, which says the part too: the paths, the first
|
||||
// with something in it.
|
||||
expect(tabs.kept, {'japanese': 'paths'});
|
||||
await tester.tap(find.widgetWithText(Tab, 'Notes'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tabs.kept, {'japanese': 'notes'});
|
||||
|
|
@ -159,6 +163,71 @@ void main() {
|
|||
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 {
|
||||
final notes = NoteRepository(noteGateway);
|
||||
addTearDown(notes.dispose);
|
||||
|
|
@ -198,8 +267,11 @@ void main() {
|
|||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
|
||||
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);
|
||||
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; '
|
||||
|
|
@ -295,7 +367,7 @@ void main() {
|
|||
);
|
||||
expect(find.textContaining('1 block was left out'), findsOneWidget);
|
||||
// The paths show by themselves.
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
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 '
|
||||
'come above the paths it adds', (tester) async {
|
||||
testWidgets('an import shows the paths at once, and how far it has come '
|
||||
'above those it adds', (tester) async {
|
||||
Uint8List path(String title) => utf8.encode(
|
||||
'{"path": {"title": "$title", "steps": ['
|
||||
'{"title": "Read", "type": "info"}]}}',
|
||||
|
|
@ -361,7 +433,7 @@ void main() {
|
|||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(shownPart(tester), StudysetPart.plan);
|
||||
expect(shownPart(tester), StudysetPart.material);
|
||||
expect(
|
||||
find.text('Importing path 1 of 2: “State-of-being”…'),
|
||||
findsOneWidget,
|
||||
|
|
@ -587,6 +659,8 @@ void main() {
|
|||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
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.pumpAndSettle();
|
||||
|
|
@ -753,6 +827,8 @@ void main() {
|
|||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
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.pumpAndSettle();
|
||||
|
|
@ -784,6 +860,8 @@ void main() {
|
|||
);
|
||||
await paths.createStep(id, StepKind.info, step(StepKind.info, 'Read'));
|
||||
await showApp(tester, RoutePaths.studyset('japanese'));
|
||||
// The paths are material; the plan only shows how far they came.
|
||||
await openPart(tester, 'Material');
|
||||
expect(
|
||||
tester.getRect(find.text('LESSON 1')).bottom,
|
||||
lessThanOrEqualTo(tester.getRect(find.text('Particles')).top),
|
||||
|
|
|
|||
|
|
@ -640,7 +640,10 @@ void main() {
|
|||
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);
|
||||
|
||||
await tester.tap(find.text('Reorder'));
|
||||
|
|
@ -657,6 +660,13 @@ void main() {
|
|||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
for (final (part, tab, icon, title, how) in [
|
||||
(
|
||||
'Material',
|
||||
'Paths',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
(
|
||||
'Material',
|
||||
'Flashcards',
|
||||
|
|
@ -678,13 +688,6 @@ void main() {
|
|||
'No files yet',
|
||||
'"Add material → Upload a PDF"',
|
||||
),
|
||||
(
|
||||
'Study plan',
|
||||
null,
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
]) {
|
||||
await openPart(tester, part);
|
||||
if (tab != null) {
|
||||
|
|
@ -945,7 +948,8 @@ void main() {
|
|||
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.
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue