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
4 changed files with 1208 additions and 127 deletions
|
|
@ -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/progress.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_path_answer_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late LearningPathRepository paths;
|
||||
late PathProgressRepository progress;
|
||||
late SettingsRepository settings;
|
||||
late String pathId;
|
||||
late String nextPathId;
|
||||
late String infoId;
|
||||
|
|
@ -23,6 +27,7 @@ void main() {
|
|||
setUp(() async {
|
||||
paths = LearningPathRepository(FakeLearningPathGateway());
|
||||
progress = PathProgressRepository(FakePathAnswerGateway());
|
||||
settings = SettingsRepository(FakeSettingsGateway());
|
||||
pathId = await paths.createPath(
|
||||
'japanese',
|
||||
LearningPathContent.titled('Particles'),
|
||||
|
|
@ -52,11 +57,13 @@ void main() {
|
|||
bloc = null;
|
||||
await progress.dispose();
|
||||
await paths.dispose();
|
||||
await settings.dispose();
|
||||
});
|
||||
|
||||
PathRunnerBloc open() => bloc = PathRunnerBloc(
|
||||
paths,
|
||||
progress,
|
||||
settings,
|
||||
'japanese',
|
||||
pathId,
|
||||
)..add(const PathRunnerEventStarted());
|
||||
|
|
@ -186,4 +193,77 @@ void main() {
|
|||
expect(afresh.progress[infoId]?.done, isFalse);
|
||||
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/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_scroll_view.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/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/data/studyset_payload.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_note_gateway.dart';
|
||||
import '../../../support/fake_path_answer_gateway.dart';
|
||||
import '../../../support/fake_settings_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_studyset_tab_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
|
|
@ -59,6 +64,7 @@ void main() {
|
|||
DeviceSettings deviceSettings = const DeviceSettings(),
|
||||
Size size = const Size(1200, 1000),
|
||||
StudysetTabStore? tabs,
|
||||
PersonSettings? settings,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
|
|
@ -73,6 +79,12 @@ void main() {
|
|||
files: files,
|
||||
deviceSettings: deviceSettings,
|
||||
studysetTabStore: tabs,
|
||||
settingsGateway: settings == null
|
||||
? null
|
||||
: (FakeSettingsGateway()..receiveRemoteRow(
|
||||
'settings',
|
||||
encodeSettingsPayload(settings),
|
||||
)),
|
||||
),
|
||||
location: location,
|
||||
);
|
||||
|
|
@ -516,6 +528,111 @@ void main() {
|
|||
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', () {
|
||||
final confetti = find.descendant(
|
||||
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', () {
|
||||
final options = const StudyOptions().copyWith(
|
||||
cardsPerRound: 50,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
|||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/device_dictionary.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 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -472,6 +473,35 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('the Study card chooses how learning paths show, Steps or '
|
||||
'Scroll, on every device of theirs (decided 10 Nov 2026)', (
|
||||
tester,
|
||||
) async {
|
||||
final settings = FakeSettingsGateway();
|
||||
tester.view.physicalSize = const Size(900, 2400);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(settingsGateway: settings),
|
||||
location: RoutePaths.settings,
|
||||
);
|
||||
expect(find.text('Learning paths'), findsOneWidget);
|
||||
SegmentedButton<PathView> views() => tester.widget(
|
||||
find.byType(SegmentedButton<PathView>),
|
||||
);
|
||||
expect(views().selected, {PathView.steps});
|
||||
|
||||
await tester.tap(find.text('Scroll'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(views().selected, {PathView.scroll});
|
||||
expect(
|
||||
(await SettingsRepository(settings).read()).study.pathView,
|
||||
PathView.scroll,
|
||||
);
|
||||
});
|
||||
|
||||
group('This instance (ADR 0021, D34)', () {
|
||||
FakeInstanceOperatorGateway operatorGateway({required bool operates}) =>
|
||||
FakeInstanceOperatorGateway()
|
||||
|
|
|
|||
Loading…
Reference in a new issue