feat(learn): a learning path's Scroll view, chosen in the study settings
A path shows either one step at a time (Steps) or on one page (Scroll, as Tomorrow University's): the steps one below the other up to the first question not answered right, answered in place with a lock below it, the page running on once it is right, Finish at its end. A text step is done once its end was on screen. The outline of the steps (done ticked, the one in view marked, those beyond the question locked, a tap scrolling there) is a column from expanded windows on, a drawer from the bar below. The choice is the person's study option (`StudyOptions.pathView`, settings schema 15), in Settings › Study and the session settings. Whats-New: Learning paths can show on one scrolling page with an outline beside them: choose Scroll under Settings › Study. Whats-New-De: Lernpfade lassen sich auf einer scrollbaren Seite mit Gliederung daneben zeigen: unter Einstellungen › Lernen „Scrollen“ wählen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3257905536
commit
690f69fa55
21 changed files with 1208 additions and 127 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2176,5 +2176,11 @@
|
||||||
"cardHintsFurigana": "Furigana",
|
"cardHintsFurigana": "Furigana",
|
||||||
"cardHintsPinyin": "Pinyin",
|
"cardHintsPinyin": "Pinyin",
|
||||||
"cardHintsReadings": "Text darüber",
|
"cardHintsReadings": "Text darüber",
|
||||||
"cardHintsReadingsUnreviewed": "Manche Lesungen wurden automatisch erstellt und nicht geprüft."
|
"cardHintsReadingsUnreviewed": "Manche Lesungen wurden automatisch erstellt und nicht geprüft.",
|
||||||
|
"settingsPathView": "Lernpfade",
|
||||||
|
"settingsPathViewHelp": "Schritte zeigt einen Schritt nach dem anderen. Scrollen zeigt einen Pfad auf einer Seite bis zu seiner nächsten Frage, mit seiner Gliederung daneben.",
|
||||||
|
"pathViewSteps": "Schritte",
|
||||||
|
"pathViewScroll": "Scrollen",
|
||||||
|
"runnerOutline": "Gliederung",
|
||||||
|
"runnerScrollLocked": "Beantworte die Frage oben, um weiterzumachen."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2678,5 +2678,17 @@
|
||||||
"cardHintsReadings": "Text above",
|
"cardHintsReadings": "Text above",
|
||||||
"@cardHintsReadings": {"description": "The hints sheet's switch showing the text above its sentences, where it is neither furigana nor pinyin."},
|
"@cardHintsReadings": {"description": "The hints sheet's switch showing the text above its sentences, where it is neither furigana nor pinyin."},
|
||||||
"cardHintsReadingsUnreviewed": "Some readings were made automatically and not reviewed.",
|
"cardHintsReadingsUnreviewed": "Some readings were made automatically and not reviewed.",
|
||||||
"@cardHintsReadingsUnreviewed": {"description": "Below a hint whose readings (from Tatoeba, made by MeCab or automatic pinyin) no one reviewed."}
|
"@cardHintsReadingsUnreviewed": {"description": "Below a hint whose readings (from Tatoeba, made by MeCab or automatic pinyin) no one reviewed."},
|
||||||
|
"settingsPathView": "Learning paths",
|
||||||
|
"@settingsPathView": {"description": "The Study settings' choice of how a learning path shows its steps."},
|
||||||
|
"settingsPathViewHelp": "Steps shows one step at a time. Scroll shows a path on one page up to its next question, with its outline beside it.",
|
||||||
|
"@settingsPathViewHelp": {"description": "Explains the two ways a learning path shows."},
|
||||||
|
"pathViewSteps": "Steps",
|
||||||
|
"@pathViewSteps": {"description": "A learning path shown one step at a time."},
|
||||||
|
"pathViewScroll": "Scroll",
|
||||||
|
"@pathViewScroll": {"description": "A learning path shown on one page, scrolled through."},
|
||||||
|
"runnerOutline": "Outline",
|
||||||
|
"@runnerOutline": {"description": "The list of a learning path's steps beside or over its page in the Scroll view."},
|
||||||
|
"runnerScrollLocked": "Answer the question above to go on.",
|
||||||
|
"@runnerScrollLocked": {"description": "Below the question a learning path's page stops at, in the Scroll view."}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12841,6 +12841,42 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Some readings were made automatically and not reviewed.'**
|
/// **'Some readings were made automatically and not reviewed.'**
|
||||||
String get cardHintsReadingsUnreviewed;
|
String get cardHintsReadingsUnreviewed;
|
||||||
|
|
||||||
|
/// The Study settings' choice of how a learning path shows its steps.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Learning paths'**
|
||||||
|
String get settingsPathView;
|
||||||
|
|
||||||
|
/// Explains the two ways a learning path shows.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Steps shows one step at a time. Scroll shows a path on one page up to its next question, with its outline beside it.'**
|
||||||
|
String get settingsPathViewHelp;
|
||||||
|
|
||||||
|
/// A learning path shown one step at a time.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Steps'**
|
||||||
|
String get pathViewSteps;
|
||||||
|
|
||||||
|
/// A learning path shown on one page, scrolled through.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Scroll'**
|
||||||
|
String get pathViewScroll;
|
||||||
|
|
||||||
|
/// The list of a learning path's steps beside or over its page in the Scroll view.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Outline'**
|
||||||
|
String get runnerOutline;
|
||||||
|
|
||||||
|
/// Below the question a learning path's page stops at, in the Scroll view.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Answer the question above to go on.'**
|
||||||
|
String get runnerScrollLocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -8204,4 +8204,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardHintsReadingsUnreviewed =>
|
String get cardHintsReadingsUnreviewed =>
|
||||||
'Manche Lesungen wurden automatisch erstellt und nicht geprüft.';
|
'Manche Lesungen wurden automatisch erstellt und nicht geprüft.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPathView => 'Lernpfade';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPathViewHelp =>
|
||||||
|
'Schritte zeigt einen Schritt nach dem anderen. Scrollen zeigt einen Pfad auf einer Seite bis zu seiner nächsten Frage, mit seiner Gliederung daneben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathViewSteps => 'Schritte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathViewScroll => 'Scrollen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runnerOutline => 'Gliederung';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runnerScrollLocked =>
|
||||||
|
'Beantworte die Frage oben, um weiterzumachen.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8131,6 +8131,25 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get cardHintsReadingsUnreviewed =>
|
String get cardHintsReadingsUnreviewed =>
|
||||||
'Some readings were made automatically and not reviewed.';
|
'Some readings were made automatically and not reviewed.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPathView => 'Learning paths';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsPathViewHelp =>
|
||||||
|
'Steps shows one step at a time. Scroll shows a path on one page up to its next question, with its outline beside it.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathViewSteps => 'Steps';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathViewScroll => 'Scroll';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runnerOutline => 'Outline';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runnerScrollLocked => 'Answer the question above to go on.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -333,9 +333,13 @@ Widget _pathRunner(BuildContext context, String studysetId, String pathId) {
|
||||||
context,
|
context,
|
||||||
studysetId,
|
studysetId,
|
||||||
(studyset, _) => BlocProvider(
|
(studyset, _) => BlocProvider(
|
||||||
create: (context) =>
|
create: (context) => PathRunnerBloc(
|
||||||
PathRunnerBloc(person.paths, person.pathProgress, studysetId, pathId)
|
person.paths,
|
||||||
..add(const PathRunnerEventStarted()),
|
person.pathProgress,
|
||||||
|
person.settings,
|
||||||
|
studysetId,
|
||||||
|
pathId,
|
||||||
|
)..add(const PathRunnerEventStarted()),
|
||||||
child: PathRunnerScreen(studysetId: studysetId, pathId: pathId),
|
child: PathRunnerScreen(studysetId: studysetId, pathId: pathId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,23 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/answer.dar
|
||||||
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_step.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
|
|
||||||
/// Leads the person through a learning path, one step at a time (decided
|
/// Leads the person through a learning path, one step at a time (decided
|
||||||
/// 2026-10-02): it opens at the first unfinished step; an info step is
|
/// 2026-10-02): it opens at the first unfinished step; an info step is
|
||||||
/// passed by going on, an exercise only once answered right, as often as it
|
/// passed by going on, an exercise only once answered right, as often as it
|
||||||
/// takes; every answer goes into the log. After the last step the path is
|
/// takes; every answer goes into the log. After the last step the path is
|
||||||
/// finished, and the studyset's next path is named.
|
/// finished, and the studyset's next path is named.
|
||||||
|
///
|
||||||
|
/// In the Scroll view (the person's `StudyOptions.pathView`, decided 10 Nov
|
||||||
|
/// 2026) the steps show on one page up to the first question not answered
|
||||||
|
/// right, which it stands at and which is answered in place; a text step is
|
||||||
|
/// done once its end was on screen (`PathRunnerEventRead`).
|
||||||
class PathRunnerBloc(
|
class PathRunnerBloc(
|
||||||
final LearningPathRepository _paths,
|
final LearningPathRepository _paths,
|
||||||
final PathProgressRepository _progress,
|
final PathProgressRepository _progress,
|
||||||
|
final SettingsRepository _settings,
|
||||||
final String _studysetId,
|
final String _studysetId,
|
||||||
final String _pathId,
|
final String _pathId,
|
||||||
) extends Bloc<PathRunnerEvent, PathRunnerState> {
|
) extends Bloc<PathRunnerEvent, PathRunnerState> {
|
||||||
|
|
@ -37,17 +45,24 @@ class PathRunnerBloc(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
on<PathRunnerEventRestarted>(_onRestarted);
|
on<PathRunnerEventRestarted>(_onRestarted);
|
||||||
|
on<PathRunnerEventRead>(_onRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text steps recorded as read in the Scroll view, so that each is
|
||||||
|
/// recorded once before its progress is read anew.
|
||||||
|
final _read = <String>{};
|
||||||
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
PathRunnerEventStarted event,
|
PathRunnerEventStarted event,
|
||||||
Emitter<PathRunnerState> emit,
|
Emitter<PathRunnerState> emit,
|
||||||
) async {
|
) async {
|
||||||
final paths = await _paths.pathsOf(_studysetId);
|
final paths = await _paths.pathsOf(_studysetId);
|
||||||
final at = paths.indexWhere((path) => path.id == _pathId);
|
final at = paths.indexWhere((path) => path.id == _pathId);
|
||||||
|
final settings = await _settings.read();
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
nextPathId: at >= 0 && at + 1 < paths.length ? paths[at + 1].id : null,
|
nextPathId: at >= 0 && at + 1 < paths.length ? paths[at + 1].id : null,
|
||||||
|
scroll: settings.study.pathView == PathView.scroll,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
|
|
@ -87,15 +102,35 @@ class PathRunnerBloc(
|
||||||
emit(state.copyWith(progress: progress));
|
emit(state.copyWith(progress: progress));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final read = state.copyWith(loaded: true, progress: progress);
|
||||||
|
if (state.scroll) {
|
||||||
|
// Opening the page: at the first question not answered right.
|
||||||
|
emit(read.at(read.gateFrom(0)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Opening: at the first step not done yet, or the first.
|
// Opening: at the first step not done yet, or the first.
|
||||||
final first = state.steps.indexWhere(
|
final first = state.steps.indexWhere(
|
||||||
(step) => !(progress[step.id]?.done ?? false),
|
(step) => !(progress[step.id]?.done ?? false),
|
||||||
);
|
);
|
||||||
emit(
|
emit(read.at(first < 0 ? 0 : first));
|
||||||
state
|
}
|
||||||
.copyWith(loaded: true, progress: progress)
|
|
||||||
.at(first < 0 ? 0 : first),
|
/// Records the text step [PathRunnerEventRead.stepId] as read, in the
|
||||||
);
|
/// Scroll view, where it shows and isn't done yet.
|
||||||
|
Future<void> _onRead(
|
||||||
|
PathRunnerEventRead event,
|
||||||
|
Emitter<PathRunnerState> emit,
|
||||||
|
) async {
|
||||||
|
final at = state.steps.indexWhere((step) => step.id == event.stepId);
|
||||||
|
if (!state.scroll ||
|
||||||
|
at < 0 ||
|
||||||
|
at > state.index ||
|
||||||
|
state.steps[at].kind.isExercise ||
|
||||||
|
(state.progress[event.stepId]?.done ?? false) ||
|
||||||
|
!_read.add(event.stepId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _progress.record(event.stepId, AnswerOutcome.continued);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the first step, the progress read anew first, so that no answer
|
/// Shows the first step, the progress read anew first, so that no answer
|
||||||
|
|
@ -107,7 +142,13 @@ class PathRunnerBloc(
|
||||||
final progress = await _progress.of({
|
final progress = await _progress.of({
|
||||||
for (final step in state.steps) step.id: step.kind,
|
for (final step in state.steps) step.id: step.kind,
|
||||||
});
|
});
|
||||||
emit(state.copyWith(progress: progress).at(0));
|
_read.clear();
|
||||||
|
final afresh = state.copyWith(progress: progress, finished: false);
|
||||||
|
emit(
|
||||||
|
state.scroll
|
||||||
|
? afresh.at(afresh.gateFrom(0), checked: const {})
|
||||||
|
: afresh.at(0),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCheckRequested(
|
Future<void> _onCheckRequested(
|
||||||
|
|
@ -121,7 +162,18 @@ class PathRunnerBloc(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final result = grade(exercise, answer);
|
final result = grade(exercise, answer);
|
||||||
emit(state.copyWith(grade: result));
|
if (state.scroll && result.right) {
|
||||||
|
// The page runs on to the next question, this one's outcome kept
|
||||||
|
// with it.
|
||||||
|
emit(
|
||||||
|
state.at(
|
||||||
|
state.gateFrom(state.index + 1),
|
||||||
|
checked: {...state.checked, step.id: result},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(grade: result));
|
||||||
|
}
|
||||||
await _progress.record(
|
await _progress.record(
|
||||||
step.id,
|
step.id,
|
||||||
result.right ? AnswerOutcome.right : AnswerOutcome.wrong,
|
result.right ? AnswerOutcome.right : AnswerOutcome.wrong,
|
||||||
|
|
@ -134,6 +186,12 @@ class PathRunnerBloc(
|
||||||
Emitter<PathRunnerState> emit,
|
Emitter<PathRunnerState> emit,
|
||||||
) async {
|
) async {
|
||||||
final step = state.step;
|
final step = state.step;
|
||||||
|
if (state.scroll) {
|
||||||
|
if (state.canFinish) {
|
||||||
|
emit(state.copyWith(finished: true));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (step == null || !state.canContinue) {
|
if (step == null || !state.canContinue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +230,39 @@ final class const PathRunnerState({
|
||||||
|
|
||||||
/// The studyset's next path, after this one; null for the last.
|
/// The studyset's next path, after this one; null for the last.
|
||||||
final String? nextPathId,
|
final String? nextPathId,
|
||||||
|
|
||||||
|
/// Whether the steps show on one page (the Scroll view), [index] the
|
||||||
|
/// question it runs on to, else the last step.
|
||||||
|
final bool scroll = false,
|
||||||
|
|
||||||
|
/// In the Scroll view, how the questions answered right on this page
|
||||||
|
/// came out, by their steps' ids.
|
||||||
|
final Map<String, Grade> checked = const {},
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
/// The first question from [from] on not answered right, else the last
|
||||||
|
/// step: where the Scroll view's page runs to.
|
||||||
|
int gateFrom(int from) {
|
||||||
|
for (var at = from; at < steps.length; at++) {
|
||||||
|
if (steps[at].kind.isExercise &&
|
||||||
|
!(progress[steps[at].id]?.done ?? false)) {
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps.isEmpty ? 0 : steps.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The steps the Scroll view shows: up to [index].
|
||||||
|
List<LearningPathStep> get shown =>
|
||||||
|
steps.isEmpty ? const [] : steps.sublist(0, index + 1);
|
||||||
|
|
||||||
|
/// Whether the Scroll view's page may finish: no question left on it
|
||||||
|
/// unanswered.
|
||||||
|
bool get canFinish =>
|
||||||
|
scroll &&
|
||||||
|
step != null &&
|
||||||
|
isLast &&
|
||||||
|
(canContinue || checked.containsKey(step!.id));
|
||||||
|
|
||||||
/// The step shown; null for a path without steps.
|
/// The step shown; null for a path without steps.
|
||||||
LearningPathStep? get step => index < steps.length ? steps[index] : null;
|
LearningPathStep? get step => index < steps.length ? steps[index] : null;
|
||||||
|
|
||||||
|
|
@ -206,8 +296,8 @@ final class const PathRunnerState({
|
||||||
};
|
};
|
||||||
|
|
||||||
/// This state at the step [index], its answer the right one given before,
|
/// This state at the step [index], its answer the right one given before,
|
||||||
/// if any.
|
/// if any; with [checked] in place of the questions answered so far.
|
||||||
PathRunnerState at(int index) {
|
PathRunnerState at(int index, {Map<String, Grade>? checked}) {
|
||||||
final step = index < steps.length ? steps[index] : null;
|
final step = index < steps.length ? steps[index] : null;
|
||||||
return PathRunnerState(
|
return PathRunnerState(
|
||||||
loaded: loaded,
|
loaded: loaded,
|
||||||
|
|
@ -216,6 +306,8 @@ final class const PathRunnerState({
|
||||||
index: index,
|
index: index,
|
||||||
answer: progress[step?.id]?.answer,
|
answer: progress[step?.id]?.answer,
|
||||||
nextPathId: nextPathId,
|
nextPathId: nextPathId,
|
||||||
|
scroll: scroll,
|
||||||
|
checked: checked ?? this.checked,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +320,8 @@ final class const PathRunnerState({
|
||||||
answer: answer,
|
answer: answer,
|
||||||
finished: finished,
|
finished: finished,
|
||||||
nextPathId: nextPathId,
|
nextPathId: nextPathId,
|
||||||
|
scroll: scroll,
|
||||||
|
checked: checked,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// This state with some fields replaced.
|
/// This state with some fields replaced.
|
||||||
|
|
@ -238,6 +332,7 @@ final class const PathRunnerState({
|
||||||
Grade? grade,
|
Grade? grade,
|
||||||
bool? finished,
|
bool? finished,
|
||||||
String? nextPathId,
|
String? nextPathId,
|
||||||
|
bool? scroll,
|
||||||
}) => PathRunnerState(
|
}) => PathRunnerState(
|
||||||
loaded: loaded ?? this.loaded,
|
loaded: loaded ?? this.loaded,
|
||||||
steps: steps ?? this.steps,
|
steps: steps ?? this.steps,
|
||||||
|
|
@ -247,6 +342,8 @@ final class const PathRunnerState({
|
||||||
grade: grade ?? this.grade,
|
grade: grade ?? this.grade,
|
||||||
finished: finished ?? this.finished,
|
finished: finished ?? this.finished,
|
||||||
nextPathId: nextPathId ?? this.nextPathId,
|
nextPathId: nextPathId ?? this.nextPathId,
|
||||||
|
scroll: scroll ?? this.scroll,
|
||||||
|
checked: checked,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -259,6 +356,8 @@ final class const PathRunnerState({
|
||||||
grade,
|
grade,
|
||||||
finished,
|
finished,
|
||||||
nextPathId,
|
nextPathId,
|
||||||
|
scroll,
|
||||||
|
checked,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,3 +391,10 @@ final class const PathRunnerEventBack() extends PathRunnerEvent;
|
||||||
|
|
||||||
/// Start again at the first step, as after the path's progress was reset.
|
/// Start again at the first step, as after the path's progress was reset.
|
||||||
final class const PathRunnerEventRestarted() extends PathRunnerEvent;
|
final class const PathRunnerEventRestarted() extends PathRunnerEvent;
|
||||||
|
|
||||||
|
/// The end of the text step [stepId] was on screen, in the Scroll view.
|
||||||
|
final class const PathRunnerEventRead(final String stepId)
|
||||||
|
extends PathRunnerEvent {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [stepId];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@ import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
|
|
||||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
|
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||||
|
|
@ -13,6 +11,8 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_p
|
||||||
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/exercise_views.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_scroll_view.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_parts.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -20,7 +20,7 @@ import 'package:material_ui/material_ui.dart';
|
||||||
/// A learning path of the studyset [studysetId], one step at a time, in the
|
/// A learning path of the studyset [studysetId], one step at a time, in the
|
||||||
/// whole window over the tabs: a bar of the steps on top with the path's
|
/// whole window over the tabs: a bar of the steps on top with the path's
|
||||||
/// options, the step in the middle, and Back and the way on below
|
/// options, the step in the middle, and Back and the way on below
|
||||||
/// (`PathRunnerBloc`).
|
/// (`PathRunnerBloc`); in the Scroll view, on one page (`PathScrollView`).
|
||||||
class const PathRunnerScreen({
|
class const PathRunnerScreen({
|
||||||
required final String studysetId,
|
required final String studysetId,
|
||||||
required final String pathId,
|
required final String pathId,
|
||||||
|
|
@ -30,52 +30,64 @@ class const PathRunnerScreen({
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
return BlocBuilder<PathRunnerBloc, PathRunnerState>(
|
return BlocBuilder<PathRunnerBloc, PathRunnerState>(
|
||||||
builder: (context, state) => TopGradient(
|
builder: (context, state) =>
|
||||||
background: Theme.of(context).colorScheme.surface,
|
state.scroll &&
|
||||||
child: Scaffold(
|
state.loaded &&
|
||||||
backgroundColor: Colors.transparent,
|
!state.finished &&
|
||||||
appBar: AppBar(
|
state.steps.isNotEmpty
|
||||||
backgroundColor: Colors.transparent,
|
? PathScrollView(
|
||||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(LucideIcons.x),
|
|
||||||
tooltip: l10n.runnerClose,
|
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
|
||||||
),
|
|
||||||
title: state.steps.isEmpty || state.finished
|
|
||||||
? null
|
|
||||||
: _StepsBar(state: state),
|
|
||||||
actions: [
|
|
||||||
if (state.step case final step? when !state.finished)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(LucideIcons.pencil),
|
|
||||||
tooltip: l10n.runnerEditStep,
|
|
||||||
onPressed: () => context.push(
|
|
||||||
RoutePaths.pathStep(studysetId, pathId, step.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.loaded)
|
|
||||||
_PathMenu(studysetId: studysetId, pathId: pathId),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: switch (state) {
|
|
||||||
PathRunnerState(loaded: false) => const Center(
|
|
||||||
child: AppLoadingIndicator(),
|
|
||||||
),
|
|
||||||
PathRunnerState(finished: true) => _Finished(
|
|
||||||
state: state,
|
state: state,
|
||||||
studysetId: studysetId,
|
menu: _PathMenu(studysetId: studysetId, pathId: pathId),
|
||||||
|
)
|
||||||
|
: TopGradient(
|
||||||
|
background: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
tooltip: l10n.runnerClose,
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: state.steps.isEmpty || state.finished
|
||||||
|
? null
|
||||||
|
: _StepsBar(state: state),
|
||||||
|
actions: [
|
||||||
|
if (state.step case final step? when !state.finished)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.pencil),
|
||||||
|
tooltip: l10n.runnerEditStep,
|
||||||
|
onPressed: () => context.push(
|
||||||
|
RoutePaths.pathStep(studysetId, pathId, step.id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.loaded)
|
||||||
|
_PathMenu(studysetId: studysetId, pathId: pathId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: switch (state) {
|
||||||
|
PathRunnerState(loaded: false) => const Center(
|
||||||
|
child: AppLoadingIndicator(),
|
||||||
|
),
|
||||||
|
PathRunnerState(finished: true) => _Finished(
|
||||||
|
state: state,
|
||||||
|
studysetId: studysetId,
|
||||||
|
),
|
||||||
|
PathRunnerState(step: null) => Center(
|
||||||
|
child: Text(l10n.runnerEmpty),
|
||||||
|
),
|
||||||
|
PathRunnerState(:final step?) => _Step(
|
||||||
|
step: step,
|
||||||
|
state: state,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
bottomNavigationBar: state.step == null || state.finished
|
||||||
|
? null
|
||||||
|
: _Footer(state: state),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
PathRunnerState(step: null) => Center(
|
|
||||||
child: Text(l10n.runnerEmpty),
|
|
||||||
),
|
|
||||||
PathRunnerState(:final step?) => _Step(step: step, state: state),
|
|
||||||
},
|
|
||||||
bottomNavigationBar: state.step == null || state.finished
|
|
||||||
? null
|
|
||||||
: _Footer(state: state),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,22 +248,7 @@ class const _Step({
|
||||||
if (content.sources.isNotEmpty)
|
if (content.sources.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: OutlinedButton.icon(
|
child: StepSourcesButton(content: content),
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
side: BorderSide(
|
|
||||||
color: theme.colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
|
||||||
label: Text(
|
|
||||||
l10n.sourcesCount(content.sources.length),
|
|
||||||
),
|
|
||||||
onPressed: () => showSourcesSheet(
|
|
||||||
context,
|
|
||||||
title: l10n.sources,
|
|
||||||
sources: content.sources,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (state.alreadyDone && state.grade == null) ...[
|
if (state.alreadyDone && state.grade == null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -281,7 +278,7 @@ class const _Step({
|
||||||
),
|
),
|
||||||
if (state.grade case final grade?) ...[
|
if (state.grade case final grade?) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_Feedback(
|
StepFeedback(
|
||||||
right: grade.right,
|
right: grade.right,
|
||||||
feedback: grade.right
|
feedback: grade.right
|
||||||
? content.rightFeedback
|
? content.rightFeedback
|
||||||
|
|
@ -295,52 +292,6 @@ class const _Step({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a check came out, and what the author has to say to it.
|
|
||||||
class const _Feedback({
|
|
||||||
required final bool right,
|
|
||||||
required final RichContent feedback,
|
|
||||||
}) extends StatelessWidget {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppLocalizations.of(context);
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final color = right ? theme.extras.success : theme.colorScheme.error;
|
|
||||||
return Card(
|
|
||||||
margin: EdgeInsets.zero,
|
|
||||||
color: color.withValues(alpha: 0.1),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
side: BorderSide(color: color),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
right ? LucideIcons.circleCheck : LucideIcons.circleX,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
right ? l10n.runnerRight : l10n.runnerWrong,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (!feedback.isBlank) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
LearnContentView(content: feedback),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Back, and the way on: check the answer, go on, or finish. The way on is
|
/// Back, and the way on: check the answer, go on, or finish. The way on is
|
||||||
/// one wide button in the middle, the same on every step: Back takes a
|
/// one wide button in the middle, the same on every step: Back takes a
|
||||||
/// round button's room on its one side, and as much stays free on the
|
/// round button's room on its one side, and as much stays free on the
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_runner_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/exercise_views.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_kind_labels.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/step_parts.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// The key of the Scroll view's page.
|
||||||
|
const pathPageKey = ValueKey('path-page');
|
||||||
|
|
||||||
|
/// The key of the Scroll view's outline, beside the page or in its drawer.
|
||||||
|
const pathOutlineKey = ValueKey('path-outline');
|
||||||
|
|
||||||
|
/// How wide the outline is.
|
||||||
|
const _outlineWidth = 280.0;
|
||||||
|
|
||||||
|
/// A learning path on one page, as Tomorrow University's Scroll view
|
||||||
|
/// (decided 10 Nov 2026, the person's `StudyOptions.pathView`): the steps
|
||||||
|
/// one below the other up to the first question not answered right, which
|
||||||
|
/// is answered in place, a line below it saying so; once answered right the
|
||||||
|
/// page runs on. A text step is done once its end was on screen. The
|
||||||
|
/// outline of the steps beside it from expanded windows on, else in a
|
||||||
|
/// drawer from the bar; a step tapped in it is scrolled to. [menu] is the
|
||||||
|
/// path's options, as the one-step view has them.
|
||||||
|
///
|
||||||
|
/// The page is one column, not a list built as it scrolls: a path holds
|
||||||
|
/// tens of steps, and a step tapped in the outline must be there to scroll
|
||||||
|
/// to.
|
||||||
|
class const PathScrollView({
|
||||||
|
required final PathRunnerState state,
|
||||||
|
required final Widget menu,
|
||||||
|
super.key,
|
||||||
|
}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<PathScrollView> createState() => _PathScrollViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PathScrollViewState() extends State<PathScrollView> {
|
||||||
|
final _scaffold = GlobalKey<ScaffoldState>();
|
||||||
|
final _viewport = GlobalKey();
|
||||||
|
|
||||||
|
/// Each step's start and end on the page, by its id.
|
||||||
|
final _starts = <String, GlobalKey>{};
|
||||||
|
final _ends = <String, GlobalKey>{};
|
||||||
|
|
||||||
|
/// The step in view, which the outline marks.
|
||||||
|
String? _inView;
|
||||||
|
|
||||||
|
GlobalKey _startOf(String id) => _starts[id] ??= GlobalKey();
|
||||||
|
|
||||||
|
GlobalKey _endOf(String id) => _ends[id] ??= GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(PathScrollView oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
_lookAfterLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_lookAfterLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _lookAfterLayout() =>
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _look());
|
||||||
|
|
||||||
|
/// Where a box is on the screen, from top to bottom; null where it isn't
|
||||||
|
/// laid out.
|
||||||
|
(double, double)? _span(GlobalKey key) {
|
||||||
|
final box = key.currentContext?.findRenderObject();
|
||||||
|
if (box is! RenderBox || !box.attached || !box.hasSize) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final top = box.localToGlobal(Offset.zero).dy;
|
||||||
|
return (top, top + box.size.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the text steps whose end is on screen as read, and finds the
|
||||||
|
/// step in view: the last starting above a third of the page.
|
||||||
|
void _look() {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final page = _span(_viewport);
|
||||||
|
if (page == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final (top, bottom) = page;
|
||||||
|
final bloc = context.read<PathRunnerBloc>();
|
||||||
|
String? inView;
|
||||||
|
for (final step in widget.state.shown) {
|
||||||
|
if (_span(_startOf(step.id)) case (final start, _)
|
||||||
|
when start <= top + (bottom - top) / 3) {
|
||||||
|
inView = step.id;
|
||||||
|
}
|
||||||
|
if (!step.kind.isExercise &&
|
||||||
|
!(widget.state.progress[step.id]?.done ?? false)) {
|
||||||
|
if (_span(_endOf(step.id)) case (final end, _) when end <= bottom) {
|
||||||
|
bloc.add(PathRunnerEventRead(step.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inView ??= widget.state.shown.firstOrNull?.id;
|
||||||
|
if (inView != _inView) {
|
||||||
|
setState(() => _inView = inView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the page to the start of the step [id], closing the drawer
|
||||||
|
/// first where it is open.
|
||||||
|
void _goTo(String id) {
|
||||||
|
if (_scaffold.currentState?.isDrawerOpen ?? false) {
|
||||||
|
_scaffold.currentState!.closeDrawer();
|
||||||
|
}
|
||||||
|
final target = _starts[id]?.currentContext;
|
||||||
|
if (target != null) {
|
||||||
|
Scrollable.ensureVisible(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final state = widget.state;
|
||||||
|
final wide = context.windowSize.isAtLeastExpanded;
|
||||||
|
final outline = _Outline(
|
||||||
|
key: pathOutlineKey,
|
||||||
|
state: state,
|
||||||
|
inView: _inView,
|
||||||
|
onTap: _goTo,
|
||||||
|
);
|
||||||
|
return TopGradient(
|
||||||
|
background: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Scaffold(
|
||||||
|
key: _scaffold,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
drawer: wide ? null : Drawer(child: SafeArea(child: outline)),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
tooltip: l10n.runnerClose,
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
|
title: _Progress(state: state),
|
||||||
|
actions: [
|
||||||
|
if (!wide)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.listTree),
|
||||||
|
tooltip: l10n.runnerOutline,
|
||||||
|
onPressed: () => _scaffold.currentState?.openDrawer(),
|
||||||
|
),
|
||||||
|
widget.menu,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (wide) ...[
|
||||||
|
SizedBox(width: _outlineWidth, child: outline),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (_) {
|
||||||
|
_look();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
key: _viewport,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 48),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: readableContentMaxWidth,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
key: pathPageKey,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final (index, step) in state.shown.indexed) ...[
|
||||||
|
if (index > 0) const Divider(height: 48),
|
||||||
|
_PageStep(
|
||||||
|
key: _startOf(step.id),
|
||||||
|
step: step,
|
||||||
|
state: state,
|
||||||
|
open:
|
||||||
|
index == state.index &&
|
||||||
|
step.kind.isExercise &&
|
||||||
|
!state.canFinish,
|
||||||
|
),
|
||||||
|
SizedBox(key: _endOf(step.id), height: 1),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (state.canFinish)
|
||||||
|
Center(
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => context
|
||||||
|
.read<PathRunnerBloc>()
|
||||||
|
.add(const PathRunnerEventContinued()),
|
||||||
|
child: Text(l10n.runnerFinish),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_Locked(text: l10n.runnerScrollLocked),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many of the path's steps are done, as a bar and in numbers.
|
||||||
|
class const _Progress({required final PathRunnerState state})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final total = state.steps.length;
|
||||||
|
final done = state.steps
|
||||||
|
.where((step) => state.progress[step.id]?.done ?? false)
|
||||||
|
.length;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: total == 0 ? 0 : done / total,
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'$done / $total',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One step on the page: its title, text and sources, and its question,
|
||||||
|
/// [open] to answer where the page stops at it, else as answered.
|
||||||
|
class const _PageStep({
|
||||||
|
required final LearningPathStep step,
|
||||||
|
required final PathRunnerState state,
|
||||||
|
required final bool open,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final content = step.content;
|
||||||
|
final exercise = content.exercise;
|
||||||
|
final body = content.body.withoutHeading(content.title);
|
||||||
|
final checked = state.checked[step.id];
|
||||||
|
final grade = open ? state.grade : checked;
|
||||||
|
void send(PathRunnerEvent event) =>
|
||||||
|
context.read<PathRunnerBloc>().add(event);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (content.title.isNotEmpty)
|
||||||
|
Text(content.title, style: theme.textTheme.titleLarge),
|
||||||
|
if (!body.isBlank) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LearnContentView(content: body),
|
||||||
|
],
|
||||||
|
if (content.sources.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12),
|
||||||
|
child: Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: StepSourcesButton(content: content),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (exercise != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Answered right before: as it was, no longer to change.
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: !open,
|
||||||
|
child: ExerciseView(
|
||||||
|
key: ValueKey(step.id),
|
||||||
|
stepId: step.id,
|
||||||
|
exercise: exercise,
|
||||||
|
answer: open ? state.answer : state.progress[step.id]?.answer,
|
||||||
|
grade: grade,
|
||||||
|
onAnswered: (answer) => send(PathRunnerEventAnswered(answer)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (open) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: state.canCheck
|
||||||
|
? () => send(const PathRunnerEventCheckRequested())
|
||||||
|
: null,
|
||||||
|
child: Text(l10n.runnerCheck),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (checked == null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
l10n.runnerAlreadyRight,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.extras.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (grade != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
StepFeedback(
|
||||||
|
right: grade.right,
|
||||||
|
feedback: grade.right
|
||||||
|
? content.rightFeedback
|
||||||
|
: content.wrongFeedback,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the page stops: [text], under a lock.
|
||||||
|
class const _Locked({required final String text}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = theme.colorScheme.onSurfaceVariant;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.lock, size: 18, color: color),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path's steps: each done ticked, the one [inView] marked, those
|
||||||
|
/// beyond the page's question locked; a step on the page tapped calls
|
||||||
|
/// [onTap] with its id.
|
||||||
|
class const _Outline({
|
||||||
|
required final PathRunnerState state,
|
||||||
|
required final String? inView,
|
||||||
|
required final ValueChanged<String> 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 ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.runnerOutline,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final (index, step) in state.steps.indexed)
|
||||||
|
_OutlineItem(
|
||||||
|
step: step,
|
||||||
|
label: step.content.title.isNotEmpty
|
||||||
|
? step.content.title
|
||||||
|
: stepKindLabel(l10n, step.kind),
|
||||||
|
kind: step.kind.isExercise ? stepKindLabel(l10n, step.kind) : null,
|
||||||
|
done: state.progress[step.id]?.done ?? false,
|
||||||
|
current: step.id == inView,
|
||||||
|
locked: index > state.index,
|
||||||
|
onTap: () => onTap(step.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _OutlineItem({
|
||||||
|
required final LearningPathStep step,
|
||||||
|
required final String label,
|
||||||
|
required final String? kind,
|
||||||
|
required final bool done,
|
||||||
|
required final bool current,
|
||||||
|
required final bool locked,
|
||||||
|
required final VoidCallback onTap,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
|
final muted = colors.onSurfaceVariant.withValues(alpha: 0.6);
|
||||||
|
return ListTile(
|
||||||
|
enabled: !locked,
|
||||||
|
selected: current,
|
||||||
|
selectedTileColor: colors.secondaryContainer.withValues(alpha: 0.5),
|
||||||
|
dense: true,
|
||||||
|
leading: Icon(
|
||||||
|
switch ((locked, done, current)) {
|
||||||
|
(true, _, _) => LucideIcons.lock,
|
||||||
|
(_, true, _) => LucideIcons.circleCheck,
|
||||||
|
(_, _, true) => LucideIcons.circleDot,
|
||||||
|
_ => LucideIcons.circle,
|
||||||
|
},
|
||||||
|
size: 20,
|
||||||
|
color: locked
|
||||||
|
? muted
|
||||||
|
: done || current
|
||||||
|
? colors.primary
|
||||||
|
: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
title: kind == null
|
||||||
|
? Text(label, maxLines: 2, overflow: TextOverflow.ellipsis)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
stepKindIcon(step.kind),
|
||||||
|
size: 12,
|
||||||
|
color: locked ? muted : colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
kind!,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: locked ? muted : colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/citation/widget/source_list.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
// What both of a path's views show of a step: Steps' one at a time and the
|
||||||
|
// Scroll view's page.
|
||||||
|
|
||||||
|
/// The sources of a step's [content], how many on a button opening them.
|
||||||
|
class const StepSourcesButton({required final StepContent content, super.key})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
icon: const Icon(LucideIcons.bookOpen, size: 18),
|
||||||
|
label: Text(l10n.sourcesCount(content.sources.length)),
|
||||||
|
onPressed: () => showSourcesSheet(
|
||||||
|
context,
|
||||||
|
title: l10n.sources,
|
||||||
|
sources: content.sources,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a check came out, and what the author has to say to it.
|
||||||
|
class const StepFeedback({
|
||||||
|
required final bool right,
|
||||||
|
required final RichContent feedback,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final color = right ? theme.extras.success : theme.colorScheme.error;
|
||||||
|
return Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: color),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
right ? LucideIcons.circleCheck : LucideIcons.circleX,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
right ? l10n.runnerRight : l10n.runnerWrong,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (!feedback.isBlank) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LearnContentView(content: feedback),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
|
|
||||||
|
/// How the person's learning paths show (`StudyOptions.pathView` of their
|
||||||
|
/// settings, decided 10 Nov 2026), as their settings hold it now; null
|
||||||
|
/// until read.
|
||||||
|
class PathViewBloc(final SettingsRepository _settings)
|
||||||
|
extends Bloc<PathViewEvent, PathView?> {
|
||||||
|
this : super(null) {
|
||||||
|
on<PathViewEventStarted>(
|
||||||
|
(event, emit) => emit.forEach(
|
||||||
|
_settings.watch(),
|
||||||
|
onData: (settings) => settings.study.pathView,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
on<PathViewEventChosen>(
|
||||||
|
(event, emit) => _settings.update(
|
||||||
|
(settings) =>
|
||||||
|
settings.withStudy(settings.study.copyWith(pathView: event.view)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What happens to how the person's learning paths show.
|
||||||
|
sealed class const PathViewEvent();
|
||||||
|
|
||||||
|
/// The settings show: follow them.
|
||||||
|
final class const PathViewEventStarted() extends PathViewEvent;
|
||||||
|
|
||||||
|
/// The person chose how learning paths show.
|
||||||
|
final class const PathViewEventChosen(final PathView view)
|
||||||
|
extends PathViewEvent;
|
||||||
|
|
@ -44,10 +44,13 @@ import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
|
||||||
// left out while there are none. Schema 14 added the private studysets not
|
// left out while there are none. Schema 14 added the private studysets not
|
||||||
// to be checked while writing (`grammarDeclined`, ADR 0024: studyset ids),
|
// to be checked while writing (`grammarDeclined`, ADR 0024: studyset ids),
|
||||||
// left out while there are none; agreeing to it is an agreement as above,
|
// left out while there are none; agreeing to it is an agreement as above,
|
||||||
// `grammar`. Older schemas read with what they lack as it comes.
|
// `grammar`. Schema 15 added how a learning path shows its steps
|
||||||
|
// (`pathView` in `study`, decided 10 Nov 2026: `steps` or `scroll`; one
|
||||||
|
// this app doesn't know reads as steps). Older schemas read with what they
|
||||||
|
// lack as it comes.
|
||||||
|
|
||||||
/// The schema this app writes.
|
/// The schema this app writes.
|
||||||
const settingsSchemaVersion = 14;
|
const settingsSchemaVersion = 15;
|
||||||
|
|
||||||
/// Encodes [settings] into their payload.
|
/// Encodes [settings] into their payload.
|
||||||
ByteData encodeSettingsPayload(PersonSettings settings) {
|
ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
|
|
@ -79,6 +82,8 @@ ByteData encodeSettingsPayload(PersonSettings settings) {
|
||||||
'typeReadings': study.typeReadings,
|
'typeReadings': study.typeReadings,
|
||||||
if (study.writeByHand != defaults.writeByHand)
|
if (study.writeByHand != defaults.writeByHand)
|
||||||
'writeByHand': study.writeByHand,
|
'writeByHand': study.writeByHand,
|
||||||
|
if (study.pathView != defaults.pathView)
|
||||||
|
'pathView': study.pathView.name,
|
||||||
},
|
},
|
||||||
if (settings.goal case final goal?)
|
if (settings.goal case final goal?)
|
||||||
'goal': {'period': goal.period.name, 'target': goal.target},
|
'goal': {'period': goal.period.name, 'target': goal.target},
|
||||||
|
|
@ -291,6 +296,7 @@ StudyOptions _study(Object? json) {
|
||||||
final bool write => write,
|
final bool write => write,
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
pathView: named(PathView.values, json['pathView']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_set
|
||||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/device_dictionary_widgets.dart';
|
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/device_dictionary_widgets.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_settings_card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_settings_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
|
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_profiles_sheet.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/bloc/path_view_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/bloc/privacy_settings_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/bloc/privacy_settings_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/citation_settings_card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/citation_settings_card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/widget/device_settings_card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/widget/device_settings_card.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/path_view_choice.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
@ -196,8 +199,8 @@ class const _PrivacyCard() extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How studying goes on this device: the Pomodoro profiles, and whether a
|
/// How studying goes: the Pomodoro profiles, whether a session shows the
|
||||||
/// session shows the timer.
|
/// timer on this device, and how the person's learning paths show.
|
||||||
class const _StudyCard() extends StatelessWidget {
|
class const _StudyCard() extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -233,6 +236,19 @@ class const _StudyCard() extends StatelessWidget {
|
||||||
DeviceSettingsEventPomodoroWhileStudyingChosen(shown),
|
DeviceSettingsEventPomodoroWhileStudyingChosen(shown),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
BlocProvider(
|
||||||
|
create: (context) =>
|
||||||
|
PathViewBloc(PersonDependencies.of(context).settings)
|
||||||
|
..add(const PathViewEventStarted()),
|
||||||
|
child: BlocBuilder<PathViewBloc, PathView?>(
|
||||||
|
builder: (context, view) => PathViewChoice(
|
||||||
|
view: view,
|
||||||
|
onChosen: (view) => context.read<PathViewBloc>().add(
|
||||||
|
PathViewEventChosen(view),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@ enum VocabularyQuizAspect() {
|
||||||
mixed,
|
mixed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a learning path shows its steps (decided 10 Nov 2026).
|
||||||
|
enum PathView() {
|
||||||
|
/// One step at a time, Continue going on to the next.
|
||||||
|
steps,
|
||||||
|
|
||||||
|
/// One page running on to the first question not answered right, as
|
||||||
|
/// Tomorrow University's Scroll view, with the steps' outline beside.
|
||||||
|
scroll,
|
||||||
|
}
|
||||||
|
|
||||||
/// How the person studies, the same on every device (in their settings).
|
/// How the person studies, the same on every device (in their settings).
|
||||||
final class const StudyOptions({
|
final class const StudyOptions({
|
||||||
/// Spaced repetition or cramming.
|
/// Spaced repetition or cramming.
|
||||||
|
|
@ -73,6 +83,9 @@ final class const StudyOptions({
|
||||||
/// stroke, on a schedule of their own; words so written are neither
|
/// stroke, on a schedule of their own; words so written are neither
|
||||||
/// quizzed nor typed.
|
/// quizzed nor typed.
|
||||||
final bool writeByHand = false,
|
final bool writeByHand = false,
|
||||||
|
|
||||||
|
/// How a learning path shows its steps.
|
||||||
|
final PathView pathView = PathView.steps,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
/// The fewest cards a round holds.
|
/// The fewest cards a round holds.
|
||||||
static const minCardsPerRound = 5;
|
static const minCardsPerRound = 5;
|
||||||
|
|
@ -103,6 +116,7 @@ final class const StudyOptions({
|
||||||
VocabularyQuizAspect? quizAspect,
|
VocabularyQuizAspect? quizAspect,
|
||||||
bool? typeReadings,
|
bool? typeReadings,
|
||||||
bool? writeByHand,
|
bool? writeByHand,
|
||||||
|
PathView? pathView,
|
||||||
}) => StudyOptions(
|
}) => StudyOptions(
|
||||||
mode: mode ?? this.mode,
|
mode: mode ?? this.mode,
|
||||||
order: order ?? this.order,
|
order: order ?? this.order,
|
||||||
|
|
@ -119,6 +133,7 @@ final class const StudyOptions({
|
||||||
quizAspect: quizAspect ?? this.quizAspect,
|
quizAspect: quizAspect ?? this.quizAspect,
|
||||||
typeReadings: typeReadings ?? this.typeReadings,
|
typeReadings: typeReadings ?? this.typeReadings,
|
||||||
writeByHand: writeByHand ?? this.writeByHand,
|
writeByHand: writeByHand ?? this.writeByHand,
|
||||||
|
pathView: pathView ?? this.pathView,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -132,5 +147,6 @@ final class const StudyOptions({
|
||||||
quizAspect,
|
quizAspect,
|
||||||
typeReadings,
|
typeReadings,
|
||||||
writeByHand,
|
writeByHand,
|
||||||
|
pathView,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// How learning paths show, Steps or Scroll (decided 10 Nov 2026), [view]
|
||||||
|
/// chosen, [onChosen] called with another; none chosen while [view] is
|
||||||
|
/// null, as before the settings were read. In Settings › Study and the
|
||||||
|
/// session settings.
|
||||||
|
class const PathViewChoice({
|
||||||
|
required final PathView? view,
|
||||||
|
required final ValueChanged<PathView> onChosen,
|
||||||
|
super.key,
|
||||||
|
}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ExplainedTitle(
|
||||||
|
l10n.settingsPathView,
|
||||||
|
help: l10n.settingsPathViewHelp,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SegmentedButton<PathView>(
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(
|
||||||
|
value: PathView.steps,
|
||||||
|
label: Text(l10n.pathViewSteps),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: PathView.scroll,
|
||||||
|
label: Text(l10n.pathViewScroll),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {?view},
|
||||||
|
emptySelectionAllowed: view == null,
|
||||||
|
onSelectionChanged: view == null
|
||||||
|
? null
|
||||||
|
: (chosen) => onChosen(chosen.single),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_settings_bloc.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/bloc/study_settings_bloc.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/study/widget/daily_limit.dart';
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/daily_limit.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/widget/path_view_choice.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/widget/tag_chip.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
|
@ -324,6 +325,11 @@ class const _Options({required final StudyOptions options})
|
||||||
help: l10n.studySettingsWriteByHandDescription,
|
help: l10n.studySettingsWriteByHandDescription,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PathViewChoice(
|
||||||
|
view: options.pathView,
|
||||||
|
onChosen: (view) => change(options.copyWith(pathView: view)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,17 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.d
|
||||||
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/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/settings/data/settings_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.dart';
|
||||||
|
|
||||||
import '../../../support/fake_learning_path_gateway.dart';
|
import '../../../support/fake_learning_path_gateway.dart';
|
||||||
import '../../../support/fake_path_answer_gateway.dart';
|
import '../../../support/fake_path_answer_gateway.dart';
|
||||||
|
import '../../../support/fake_settings_gateway.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late LearningPathRepository paths;
|
late LearningPathRepository paths;
|
||||||
late PathProgressRepository progress;
|
late PathProgressRepository progress;
|
||||||
|
late SettingsRepository settings;
|
||||||
late String pathId;
|
late String pathId;
|
||||||
late String nextPathId;
|
late String nextPathId;
|
||||||
late String infoId;
|
late String infoId;
|
||||||
|
|
@ -23,6 +27,7 @@ void main() {
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||||
progress = PathProgressRepository(FakePathAnswerGateway());
|
progress = PathProgressRepository(FakePathAnswerGateway());
|
||||||
|
settings = SettingsRepository(FakeSettingsGateway());
|
||||||
pathId = await paths.createPath(
|
pathId = await paths.createPath(
|
||||||
'japanese',
|
'japanese',
|
||||||
LearningPathContent.titled('Particles'),
|
LearningPathContent.titled('Particles'),
|
||||||
|
|
@ -52,11 +57,13 @@ void main() {
|
||||||
bloc = null;
|
bloc = null;
|
||||||
await progress.dispose();
|
await progress.dispose();
|
||||||
await paths.dispose();
|
await paths.dispose();
|
||||||
|
await settings.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
PathRunnerBloc open() => bloc = PathRunnerBloc(
|
PathRunnerBloc open() => bloc = PathRunnerBloc(
|
||||||
paths,
|
paths,
|
||||||
progress,
|
progress,
|
||||||
|
settings,
|
||||||
'japanese',
|
'japanese',
|
||||||
pathId,
|
pathId,
|
||||||
)..add(const PathRunnerEventStarted());
|
)..add(const PathRunnerEventStarted());
|
||||||
|
|
@ -186,4 +193,77 @@ void main() {
|
||||||
expect(afresh.progress[infoId]?.done, isFalse);
|
expect(afresh.progress[infoId]?.done, isFalse);
|
||||||
expect(afresh.answer, isNull);
|
expect(afresh.answer, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('in the Scroll view (decided 10 Nov 2026)', () {
|
||||||
|
late String afterId;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
afterId = (await paths.createStep(
|
||||||
|
pathId,
|
||||||
|
StepKind.info,
|
||||||
|
StepContent.empty(StepKind.info).withField(StepField.title, 'After'),
|
||||||
|
)).stepId;
|
||||||
|
await settings.update(
|
||||||
|
(current) =>
|
||||||
|
current.withStudy(const StudyOptions(pathView: PathView.scroll)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the steps up to the first question not answered right, '
|
||||||
|
'and stands at it; a text step is done once read to its end, never '
|
||||||
|
'one beyond the question', () async {
|
||||||
|
open();
|
||||||
|
final opened = await until((s) => s.loaded);
|
||||||
|
expect(opened.scroll, isTrue);
|
||||||
|
expect(opened.step?.id, choiceId);
|
||||||
|
expect([for (final step in opened.shown) step.id], [infoId, choiceId]);
|
||||||
|
expect(opened.canFinish, isFalse);
|
||||||
|
|
||||||
|
bloc!
|
||||||
|
..add(PathRunnerEventRead(infoId))
|
||||||
|
..add(PathRunnerEventRead(afterId));
|
||||||
|
await until((s) => s.progress[infoId]?.done ?? false);
|
||||||
|
expect(await done(infoId), isTrue);
|
||||||
|
expect(
|
||||||
|
(await progress.of({afterId: StepKind.info}))[afterId]?.done ?? false,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a question answered wrong stays; answered right, the steps after '
|
||||||
|
'it show, and how it came out stays with it', () async {
|
||||||
|
open();
|
||||||
|
await until((s) => s.loaded);
|
||||||
|
bloc!
|
||||||
|
..add(const PathRunnerEventAnswered(SingleChoiceAnswer(1)))
|
||||||
|
..add(const PathRunnerEventCheckRequested());
|
||||||
|
final wrong = await until((s) => s.grade != null);
|
||||||
|
expect(wrong.step?.id, choiceId);
|
||||||
|
expect(wrong.shown, hasLength(2));
|
||||||
|
|
||||||
|
bloc!
|
||||||
|
..add(const PathRunnerEventAnswered(SingleChoiceAnswer(0)))
|
||||||
|
..add(const PathRunnerEventCheckRequested());
|
||||||
|
final right = await until((s) => s.shown.length == 3);
|
||||||
|
expect(right.checked[choiceId]?.right, isTrue);
|
||||||
|
expect(right.canFinish, isTrue);
|
||||||
|
expect(await done(choiceId), isTrue);
|
||||||
|
|
||||||
|
bloc!.add(const PathRunnerEventContinued());
|
||||||
|
expect((await until((s) => s.finished)).nextPathId, nextPathId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens with every step shown once its questions were answered '
|
||||||
|
'right before', () async {
|
||||||
|
await progress.record(
|
||||||
|
choiceId,
|
||||||
|
AnswerOutcome.right,
|
||||||
|
const SingleChoiceAnswer(0),
|
||||||
|
);
|
||||||
|
open();
|
||||||
|
final opened = await until((s) => s.loaded);
|
||||||
|
expect(opened.shown, hasLength(3));
|
||||||
|
expect(opened.canFinish, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,12 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/progress.d
|
||||||
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/learning_path/widget/path_runner_screen.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_scroll_view.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/settings/data/settings_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/study/model/study_options.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';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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/data/studyset_tabs.dart';
|
||||||
|
|
@ -29,6 +33,7 @@ import 'package:material_ui/material_ui.dart';
|
||||||
import '../../../support/fake_learning_path_gateway.dart';
|
import '../../../support/fake_learning_path_gateway.dart';
|
||||||
import '../../../support/fake_note_gateway.dart';
|
import '../../../support/fake_note_gateway.dart';
|
||||||
import '../../../support/fake_path_answer_gateway.dart';
|
import '../../../support/fake_path_answer_gateway.dart';
|
||||||
|
import '../../../support/fake_settings_gateway.dart';
|
||||||
import '../../../support/fake_studyset_gateway.dart';
|
import '../../../support/fake_studyset_gateway.dart';
|
||||||
import '../../../support/memory_studyset_tab_store.dart';
|
import '../../../support/memory_studyset_tab_store.dart';
|
||||||
import '../../../support/pump_app.dart';
|
import '../../../support/pump_app.dart';
|
||||||
|
|
@ -59,6 +64,7 @@ void main() {
|
||||||
DeviceSettings deviceSettings = const DeviceSettings(),
|
DeviceSettings deviceSettings = const DeviceSettings(),
|
||||||
Size size = const Size(1200, 1000),
|
Size size = const Size(1200, 1000),
|
||||||
StudysetTabStore? tabs,
|
StudysetTabStore? tabs,
|
||||||
|
PersonSettings? settings,
|
||||||
}) async {
|
}) async {
|
||||||
tester.view.physicalSize = size;
|
tester.view.physicalSize = size;
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
|
|
@ -73,6 +79,12 @@ void main() {
|
||||||
files: files,
|
files: files,
|
||||||
deviceSettings: deviceSettings,
|
deviceSettings: deviceSettings,
|
||||||
studysetTabStore: tabs,
|
studysetTabStore: tabs,
|
||||||
|
settingsGateway: settings == null
|
||||||
|
? null
|
||||||
|
: (FakeSettingsGateway()..receiveRemoteRow(
|
||||||
|
'settings',
|
||||||
|
encodeSettingsPayload(settings),
|
||||||
|
)),
|
||||||
),
|
),
|
||||||
location: location,
|
location: location,
|
||||||
);
|
);
|
||||||
|
|
@ -516,6 +528,111 @@ void main() {
|
||||||
expect(find.text('2 steps complete.'), findsOneWidget);
|
expect(find.text('2 steps complete.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('the Scroll view (decided 10 Nov 2026)', () {
|
||||||
|
const scrolling = PersonSettings(
|
||||||
|
study: StudyOptions(pathView: PathView.scroll),
|
||||||
|
);
|
||||||
|
final page = find.byKey(pathPageKey);
|
||||||
|
final outline = find.byKey(pathOutlineKey);
|
||||||
|
Finder onPage(String text) =>
|
||||||
|
find.descendant(of: page, matching: find.text(text));
|
||||||
|
Finder inOutline(String text) =>
|
||||||
|
find.descendant(of: outline, matching: find.text(text));
|
||||||
|
|
||||||
|
/// A path of a text step, a question and another text step.
|
||||||
|
Future<(String, String)> particles() async {
|
||||||
|
final paths = repository();
|
||||||
|
final id = await paths.createPath(
|
||||||
|
'japanese',
|
||||||
|
LearningPathContent.titled('Particles'),
|
||||||
|
);
|
||||||
|
final (:stepId, revisionId: _) = await paths.createStep(
|
||||||
|
id,
|
||||||
|
StepKind.info,
|
||||||
|
step(StepKind.info, 'Read'),
|
||||||
|
);
|
||||||
|
await paths.createStep(
|
||||||
|
id,
|
||||||
|
StepKind.singleChoice,
|
||||||
|
step(
|
||||||
|
StepKind.singleChoice,
|
||||||
|
'Which marks the topic?',
|
||||||
|
const SingleChoice(options: ['は', 'が'], correct: 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await paths.createStep(id, StepKind.info, step(StepKind.info, 'After'));
|
||||||
|
return (id, stepId);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('the steps run on one page up to the first question not '
|
||||||
|
'answered right, which is answered in place; then the page runs on '
|
||||||
|
'to the finish; beside it the outline, the steps beyond locked', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final (id, readId) = await particles();
|
||||||
|
await showApp(
|
||||||
|
tester,
|
||||||
|
RoutePaths.path('japanese', id),
|
||||||
|
settings: scrolling,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onPage('Read'), findsOneWidget);
|
||||||
|
expect(onPage('Which marks the topic?'), findsOneWidget);
|
||||||
|
expect(onPage('After'), findsNothing);
|
||||||
|
expect(inOutline('After'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Continue'), findsNothing);
|
||||||
|
// The text step above the question was read to its end.
|
||||||
|
final progress = PathProgressRepository(answers);
|
||||||
|
addTearDown(progress.dispose);
|
||||||
|
expect(
|
||||||
|
(await progress.of({readId: StepKind.info}))[readId]?.done,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('が'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Not quite'), findsOneWidget);
|
||||||
|
expect(onPage('After'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.text('は'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Check answers'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Correct!'), findsOneWidget);
|
||||||
|
expect(onPage('After'), findsOneWidget);
|
||||||
|
|
||||||
|
final finish = find.widgetWithText(FilledButton, 'Finish');
|
||||||
|
await tester.ensureVisible(finish);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(finish);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('You finished this path'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('on a phone the outline opens from the bar, and a step '
|
||||||
|
'tapped in it is scrolled to', (tester) async {
|
||||||
|
final (id, _) = await particles();
|
||||||
|
await showApp(
|
||||||
|
tester,
|
||||||
|
RoutePaths.path('japanese', id),
|
||||||
|
settings: scrolling,
|
||||||
|
size: const Size(400, 800),
|
||||||
|
);
|
||||||
|
expect(outline, findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Outline'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(inOutline('After'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(inOutline('Which marks the topic?'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(outline, findsNothing);
|
||||||
|
expect(onPage('Which marks the topic?'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('finishing a path', () {
|
group('finishing a path', () {
|
||||||
final confetti = find.descendant(
|
final confetti = find.descendant(
|
||||||
of: find.byType(ConfettiBurst),
|
of: find.byType(ConfettiBurst),
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,27 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a path's view survives a round trip, in the 15th schema; a payload "
|
||||||
|
"from before, or a view this app doesn't know, has Steps", () {
|
||||||
|
const settings = PersonSettings(
|
||||||
|
study: StudyOptions(pathView: PathView.scroll),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(settingsSchemaVersion, greaterThanOrEqualTo(15));
|
||||||
|
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
|
||||||
|
for (final study in [
|
||||||
|
<String, Object?>{},
|
||||||
|
{'pathView': 'carousel'},
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
decodeSettingsPayload(
|
||||||
|
encodePayload(14, {'studyTags': {}, 'study': study}),
|
||||||
|
).study.pathView,
|
||||||
|
PathView.steps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('study options keep their numbers in range', () {
|
test('study options keep their numbers in range', () {
|
||||||
final options = const StudyOptions().copyWith(
|
final options = const StudyOptions().copyWith(
|
||||||
cardsPerRound: 50,
|
cardsPerRound: 50,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue