feat(app): the studyset screen laid out as before

The Study pill reads "Learn 12 flashcards" in the app bar and "Learn 12"
floating, "Study" with nothing due, and is off with nothing to study,
saying so. With a path not finished, a chevron beside it offers to start
or continue the path: a menu in the app bar, a Study mode sheet on
phones. The floating bar rises above the licence line, which takes two
lines at most.

Add material offers a path, flashcards, a note and a PDF, in that order;
imports moved to the options' Import content, which asks for a learning
path or an Anki deck. The options read Edit, Move, Study options, Import
content, Reset progress, Archive, Delete. The bibliography is an outlined
round button above the content, and on phones at the foot of the topics.

The tabs sit on a line of their own across the card, what the tab shown
offers beside them: the flashcards' filter and options, and on the paths
Reorder once there are two, which turns them into handles to drag. An
empty tab says so in its middle; tapping the open tab again returns to
its top, and pulling a tab down syncs. The breadcrumb's steps are plain
links between "›", the studyset itself bold. A long description is cut at
a fixed height, measured, with Read more opening it whole under the
studyset's title. A studyset that no longer exists leads back to the
library.

The topics: All topics on the primary colour, in view also while they
are rearranged; each topic with the study it invites in a round icon,
what it holds ("Empty" with nothing, files counted too), and its options
(now with Reset progress) behind "…" or a long press where the finger
is; New sub-studyset at the foot below a line, the list with its
scrollbar always shown. All topics opens Add material while nothing is
due.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-08 11:42:50 +02:00
commit 72b29c7e80
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 1949 additions and 616 deletions

View file

@ -73,12 +73,18 @@ void main() {
await tester.pumpAndSettle();
}
/// Chooses Import from Anki under Add material.
/// Chooses an Anki deck under Options › Import content.
Future<void> chooseImport(WidgetTester tester) async {
await tester.tap(find.text('Add material'));
await tester.pump();
await tester.pump();
await tester.tap(find.text('Import from Anki'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Anki deck'));
await tester.pump();
await tester.pump();
await tester.pump();

View file

@ -198,9 +198,17 @@ void main() {
);
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Add material'));
await tester.tap(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Import content'));
await tester.pumpAndSettle();
await tester.tap(find.text('Learning path'));
await tester.pumpAndSettle();
await chooseInMenu(tester, 'Import a path (JSON)');
expect(files.asked.single, ['json']);
expect(

View file

@ -6,6 +6,13 @@ import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
import 'package:halkyon_learn_flutter/src/core/widget/palette_color_picker.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_runner_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/studyset_study_options.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -16,6 +23,8 @@ import 'package:material_ui/material_ui.dart';
import '../../../support/fake_auth_gateway.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_learning_path_gateway.dart';
import '../../../support/fake_note_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -40,10 +49,14 @@ RichContent paragraph(String text) => RichContent({
void main() {
late FakeStudysetGateway gateway;
late FakeCardGateway cardGateway;
late FakeLearningPathGateway pathGateway;
late FakeNoteGateway noteGateway;
setUp(() {
noteGateway = FakeNoteGateway();
gateway = FakeStudysetGateway();
cardGateway = FakeCardGateway();
pathGateway = FakeLearningPathGateway();
});
/// Shows the app at [location] in a window [width] wide. Every person
@ -60,7 +73,12 @@ void main() {
await pumpApp(
tester,
auth == null
? testDependencies(studysetGateway: gateway, cardGateway: cardGateway)
? testDependencies(
studysetGateway: gateway,
cardGateway: cardGateway,
pathGateway: pathGateway,
noteGateway: noteGateway,
)
: testDependencies(authGateway: auth),
location: location,
);
@ -145,13 +163,18 @@ void main() {
expect(step('Library'), findsOneWidget);
expect(step('Japanese'), findsOneWidget);
expect(step('Kana'), findsOneWidget);
expect(step('›'), findsNWidgets(2));
expect(
find.textContaining('The two syllabaries.', findRichText: true),
findsOneWidget,
);
// Short, it shows whole.
expect(find.text('Read more'), findsNothing);
expect(find.text('Topics'), findsOneWidget);
expect(find.text('Hiragana'), findsOneWidget);
expect(find.text('All topics'), findsOneWidget);
// What the topic and all topics hold: nothing.
expect(find.text('Empty'), findsNWidgets(2));
expect(find.text('Flashcards'), findsOneWidget);
expect(find.text('No flashcards yet'), findsOneWidget);
});
@ -211,18 +234,193 @@ void main() {
);
});
testWidgets('reordering the topics keeps All topics in view', (
tester,
) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
stored('kanji', 'Kanji', parent: 'japanese', position: 'a1');
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.byTooltip('Reorder'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
expect(find.text('All topics'), findsOneWidget);
});
testWidgets('a long press on a topic opens its options: edit, reset its '
'progress, delete', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await openFromLibrary(tester, 'Japanese');
await tester.longPress(find.text('Kana'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Reset progress'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
});
testWidgets('studies everything inside a studyset', (tester) async {
stored('japanese', 'Japanese');
stored('kana', 'Kana', parent: 'japanese');
await card('kana', 'あ');
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.text('Learn 1'));
await tester.tap(find.text('Learn 1 flashcard'));
await tester.pumpAndSettle();
expect(find.textContaining('あ', findRichText: true), findsOneWidget);
});
testWidgets('with nothing to study, Study is off and says why', (
tester,
) async {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
expect(find.text('Study'), findsOneWidget);
expect(find.byTooltip('Nothing to study yet'), findsOneWidget);
});
testWidgets('with a path not finished, Study offers to start it beside the '
'flashcards', (tester) async {
stored('japanese', 'Japanese');
await card('japanese', '日本');
final paths = LearningPathRepository(pathGateway);
addTearDown(paths.dispose);
final path = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.byTooltip('Study mode'));
await tester.pumpAndSettle();
expect(find.text('Learn 1 flashcard'), findsNWidgets(2));
await tester.tap(find.text('Start: Particles'));
await tester.pumpAndSettle();
expect(find.byType(PathRunnerScreen), findsOneWidget);
});
testWidgets('on a phone, Study with a path not finished asks how to study '
'in a sheet', (tester) async {
stored('japanese', 'Japanese');
await card('japanese', '日本');
final paths = LearningPathRepository(pathGateway);
addTearDown(paths.dispose);
final path = await paths.createPath(
'japanese',
LearningPathContent.titled('Particles'),
);
await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 400,
);
await tester.tap(find.byTooltip('Study mode'));
await tester.pumpAndSettle();
expect(find.text('Spaced repetition'), findsOneWidget);
expect(find.text('Begin the first step of this path.'), findsOneWidget);
});
testWidgets('Add material offers a path, flashcards, a note and a PDF, in '
'that order; imports are under Options', (tester) async {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
final tops = [
for (final item in [
'Create a path',
'Create flashcards',
'Create a note',
'Upload a PDF',
])
tester.getRect(find.text(item)).top,
];
expect(tops, [...tops]..sort());
expect(find.text('Import from Anki'), findsNothing);
await tester.tapAt(const Offset(10, 990));
await tester.pumpAndSettle();
await choose(tester, 'Import content');
expect(find.text('Anki deck'), findsOneWidget);
expect(find.text('Learning path'), findsOneWidget);
});
testWidgets('the bibliography is a button above the content, not in the '
'app bar; on a phone at the foot of the topics', (tester) async {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.byTooltip('Bibliography'),
),
findsNothing,
);
expect(find.byTooltip('Bibliography'), findsOneWidget);
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: 400,
);
expect(find.byTooltip('Bibliography'), findsNothing);
await tester.tap(find.byTooltip('Show topics'));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(Drawer),
matching: find.byTooltip('Bibliography'),
),
findsOneWidget,
);
});
testWidgets('with two paths or more, Reorder beside the tabs lets them be '
'dragged, until Done', (tester) async {
stored('japanese', 'Japanese');
final paths = LearningPathRepository(pathGateway);
addTearDown(paths.dispose);
for (final title in ['Particles', 'Verbs']) {
final path = await paths.createPath(
'japanese',
LearningPathContent.titled(title),
);
await paths.createStep(
path,
StepKind.info,
StepContent.empty(StepKind.info),
);
}
await openFromLibrary(tester, 'Japanese');
expect(find.byTooltip('Drag to reorder'), findsNothing);
await tester.tap(find.text('Reorder'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNWidgets(2));
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.byTooltip('Drag to reorder'), findsNothing);
});
testWidgets('with cards of its own and in its sub-studysets, asks what to '
'study: everything, or it alone', (tester) async {
stored('japanese', 'Japanese');
@ -231,7 +429,7 @@ void main() {
await card('kana', 'あ');
await openFromLibrary(tester, 'Japanese');
await tester.tap(find.text('Learn 2'));
await tester.tap(find.text('Learn 2 flashcards'));
await tester.pumpAndSettle();
expect(find.text('Study scope'), findsOneWidget);
await tester.tap(find.text('This studyset only'));
@ -514,6 +712,54 @@ void main() {
expect(find.text('Kana'), findsOneWidget);
});
testWidgets('a long description is cut short, the whole of it a tap on '
'Read more away', (tester) async {
stored(
'grammar',
'Grammar',
description: paragraph(
[for (var i = 1; i <= 40; i++) 'Line $i of the grammar.'].join(' '),
),
);
await showApp(tester, location: RoutePaths.studyset('grammar'));
await tester.pumpAndSettle();
await tester.tap(find.text('Read more'));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(SheetScaffold),
matching: find.text('Grammar'),
),
findsOneWidget,
);
});
testWidgets('tapping the open tab again goes back to its top', (
tester,
) async {
stored('japanese', 'Japanese');
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
for (var i = 1; i <= 30; i++) {
await notes.create(
'japanese',
NoteContent(title: 'Note $i', body: RichContent.empty()),
);
}
await openFromLibrary(tester, 'Japanese');
// On screen, not merely built.
final first = find.text('Note 1').hitTestable();
expect(first, findsOneWidget);
await tester.drag(find.text('Note 3'), const Offset(0, -600));
await tester.pumpAndSettle();
expect(first, findsNothing);
await tester.tap(find.text('Notes'));
await tester.pumpAndSettle();
expect(first, findsOneWidget);
});
testWidgets('shows its licence', (tester) async {
stored(
'grammar',
@ -525,7 +771,7 @@ void main() {
await showApp(tester, location: RoutePaths.studyset('grammar'));
expect(find.text('© 2006 Tae Kim'), findsOneWidget);
expect(find.textContaining('© 2006 Tae Kim'), findsOneWidget);
expect(find.text('CC-BY-SA-4.0 ↗'), findsOneWidget);
});
@ -567,9 +813,14 @@ void main() {
expect(find.textContaining('no longer exists'), findsOneWidget);
});
testWidgets('a studyset that does not exist says so', (tester) async {
testWidgets('a studyset that does not exist says so, and leads back to '
'the library', (tester) async {
stored('japanese', 'Japanese');
await showApp(tester, location: RoutePaths.studyset('studyset-9'));
expect(find.textContaining('no longer exists'), findsOneWidget);
await tester.tap(find.text('Back to library'));
await tester.pumpAndSettle();
expect(find.text('Japanese'), findsOneWidget);
});
}