feat(studyset): Material, Progress and Study plan in place of the tabs
The studyset's four tabs become three parts, as Vaia has them, chosen with a switch in the header that ticks as the navigation bar does: below expanded windows in a line of its own under the title, above the path and Topics; from there on at the end of the path's line, which the topics leave free there. - Material: the flashcards, notes and files one below the other, the first three of each with Show all; Browse, the tag filter (where all cards show, or while they are narrowed) and Reorder in each section's header; an outlined box where a section is empty. - Progress, once anything in the studyset was studied (a card rated, a path finished): how much is learnt (mature cards), how many studied cards are due today, and how long it was studied this week on this device, with a line of the seven days (StudysetActivityCubit, from the reviews' durations). - Study plan: a ring of the paths finished, Next up (study the cards due, go on with the first path not finished), then the paths. It opens on the part it was left on; a tab kept before counts as its part. Opened for the first time, it opens on the study plan while a path is unfinished, else on the material. Material added lands in its part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
1df60c8830
commit
246c9dc143
2 changed files with 1992 additions and 482 deletions
|
|
@ -0,0 +1,105 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.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/study/data/review_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/review.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_review_gateway.dart';
|
||||
|
||||
void main() {
|
||||
late CardRepository cards;
|
||||
late ReviewRepository reviews;
|
||||
|
||||
setUp(() {
|
||||
cards = CardRepository(FakeCardGateway());
|
||||
reviews = ReviewRepository(FakeReviewGateway());
|
||||
addTearDown(cards.dispose);
|
||||
addTearDown(reviews.dispose);
|
||||
});
|
||||
|
||||
Future<String> card(String studysetId) => cards.create(
|
||||
studysetId,
|
||||
CardContent(
|
||||
front: RichContent.plain('猫'),
|
||||
back: RichContent.plain('cat'),
|
||||
),
|
||||
);
|
||||
|
||||
/// The first activity read for [studysetIds].
|
||||
Future<StudysetActivityState> read(List<String> studysetIds) async {
|
||||
final cubit = StudysetActivityCubit(cards, reviews, studysetIds);
|
||||
addTearDown(cubit.close);
|
||||
return (await cubit.stream.firstWhere((state) => state != null))!;
|
||||
}
|
||||
|
||||
test('nothing studied yet: no studying, and no time this week', () async {
|
||||
await card('japanese');
|
||||
|
||||
final activity = await read(['japanese']);
|
||||
|
||||
expect(activity.studied, isFalse);
|
||||
expect(activity.week, List.filled(7, Duration.zero));
|
||||
});
|
||||
|
||||
test('sums each day of the last seven, today last, across the studyset '
|
||||
'and what is inside it; a fresh start is no studying', () async {
|
||||
final now = DateTime(2026, 10, 12, 15);
|
||||
final own = await card('japanese');
|
||||
final inTopic = await card('grammar');
|
||||
final elsewhere = await card('french');
|
||||
Future<void> review(
|
||||
String cardId,
|
||||
DateTime at,
|
||||
int minutes, {
|
||||
StudyMode mode = StudyMode.review,
|
||||
}) => reviews.record(
|
||||
cardId: cardId,
|
||||
rating: Rating.good,
|
||||
reviewedAt: at,
|
||||
duration: Duration(minutes: minutes),
|
||||
mode: mode,
|
||||
);
|
||||
await review(own, now, 2);
|
||||
await review(inTopic, now.subtract(const Duration(hours: 2)), 1);
|
||||
await review(own, now.subtract(const Duration(days: 1)), 3);
|
||||
await review(own, now.subtract(const Duration(days: 6)), 4);
|
||||
// A week ago and more, another studyset's, a fresh start: none counts.
|
||||
await review(own, now.subtract(const Duration(days: 7)), 5);
|
||||
await review(elsewhere, now, 6);
|
||||
await review(own, now, 7, mode: StudyMode.reset);
|
||||
|
||||
final activity = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => read(['japanese', 'grammar']),
|
||||
);
|
||||
|
||||
expect(activity.studied, isTrue);
|
||||
expect(activity.week, [
|
||||
const Duration(minutes: 4),
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
Duration.zero,
|
||||
const Duration(minutes: 3),
|
||||
const Duration(minutes: 3),
|
||||
]);
|
||||
});
|
||||
|
||||
test('only a fresh start is no studying', () async {
|
||||
final own = await card('japanese');
|
||||
await reviews.record(
|
||||
cardId: own,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 1),
|
||||
mode: StudyMode.reset,
|
||||
);
|
||||
|
||||
expect((await read(['japanese'])).studied, isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/citation/model/source.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
|
|
@ -19,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_conte
|
|||
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/rating.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';
|
||||
|
|
@ -26,6 +30,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/library_screen.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_breadcrumb.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_parts.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_rail.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
|
@ -597,55 +602,59 @@ void main() {
|
|||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('every empty tab shows its icon, that there is nothing yet, '
|
||||
'and how to add some', (tester) async {
|
||||
testWidgets('every empty section shows its icon, that there is nothing '
|
||||
'yet, and how to add some', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
|
||||
for (final (tab, icon, title, how) in [
|
||||
for (final (part, icon, title, how) in [
|
||||
(
|
||||
'Flashcards',
|
||||
'Material',
|
||||
LucideIcons.layers,
|
||||
'No flashcards yet',
|
||||
'"Add material → Create flashcards"',
|
||||
),
|
||||
(
|
||||
'Paths',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
(
|
||||
'Notes',
|
||||
'Material',
|
||||
LucideIcons.notebookPen,
|
||||
'No notes yet',
|
||||
'"Add material → Create a note"',
|
||||
),
|
||||
(
|
||||
'Files',
|
||||
'Material',
|
||||
LucideIcons.folder,
|
||||
'No files yet',
|
||||
'"Add material → Upload a PDF"',
|
||||
),
|
||||
(
|
||||
'Study plan',
|
||||
LucideIcons.route,
|
||||
'No paths yet',
|
||||
'"Add material → Create a path"',
|
||||
),
|
||||
]) {
|
||||
await tester.tap(find.text(tab));
|
||||
await tester.pumpAndSettle();
|
||||
final message = find.byType(EmptyListMessage);
|
||||
expect(message, findsOneWidget, reason: tab);
|
||||
await openPart(tester, part);
|
||||
// The box that says so, around its icon, title and hint.
|
||||
final box = find.ancestor(
|
||||
of: find.text(title),
|
||||
matching: find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is EmptyListMessage ||
|
||||
(widget is DecoratedBox &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration as BoxDecoration).border != null),
|
||||
),
|
||||
);
|
||||
expect(box, findsOneWidget, reason: title);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.byIcon(icon)),
|
||||
find.descendant(of: box, matching: find.byIcon(icon)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.text(title)),
|
||||
find.descendant(of: box, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: message, matching: find.textContaining(how)),
|
||||
findsOneWidget,
|
||||
reason: tab,
|
||||
reason: title,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -868,7 +877,7 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
testWidgets('with two notes or more, Reorder beside the tabs lets them be '
|
||||
testWidgets('with two notes or more, Reorder beside them lets them be '
|
||||
'dragged into another order, until Done', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final notes = NoteRepository(noteGateway);
|
||||
|
|
@ -880,7 +889,7 @@ void main() {
|
|||
);
|
||||
}
|
||||
await openFromLibrary(tester, 'Japanese');
|
||||
// It opens on the notes, its only material.
|
||||
// It opens on the material, the notes among it.
|
||||
expect(find.byTooltip('Drag to reorder'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Reorder'));
|
||||
|
|
@ -1485,7 +1494,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
testWidgets('tapping the open tab again goes back to its top', (
|
||||
testWidgets('tapping the open part again goes back to its top', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
|
|
@ -1501,12 +1510,15 @@ void main() {
|
|||
// On screen, not merely built.
|
||||
final first = find.text('Note 1').hitTestable();
|
||||
expect(first, findsOneWidget);
|
||||
// The first three, until all show.
|
||||
expect(find.text('Note 4'), findsNothing);
|
||||
await tester.tap(find.text('Show all'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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();
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(first, findsOneWidget);
|
||||
});
|
||||
|
|
@ -1574,4 +1586,106 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
/// The parts the studyset screen offers, by their names.
|
||||
List<String> parts(WidgetTester tester) => [
|
||||
for (final part
|
||||
in tester
|
||||
.widget<StudysetPartSwitch>(find.byType(StudysetPartSwitch))
|
||||
.parts)
|
||||
studysetPartName(
|
||||
AppLocalizations.of(tester.element(find.byType(StudysetScreen))),
|
||||
part,
|
||||
),
|
||||
];
|
||||
|
||||
testWidgets('the parts are switched in the header: on a phone under the '
|
||||
"title, above the path; on a desktop at the end of the path's line", (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
await showApp(
|
||||
tester,
|
||||
location: RoutePaths.studyset('japanese'),
|
||||
width: 400,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
Rect switchRect() => tester.getRect(find.byType(StudysetPartSwitch));
|
||||
Rect pathRect() => tester.getRect(find.byType(StudysetBreadcrumb));
|
||||
expect(switchRect().bottom, lessThanOrEqualTo(pathRect().top));
|
||||
expect(switchRect().width, greaterThan(300));
|
||||
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(StudysetBreadcrumb),
|
||||
matching: find.byType(StudysetPartSwitch),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
// The path's line stays as high as its steps.
|
||||
expect(pathRect().height, StudysetBreadcrumb.minTarget);
|
||||
expect(switchRect().right, greaterThan(pathRect().right - 16));
|
||||
});
|
||||
|
||||
testWidgets('Progress shows once a card in the studyset was studied: how '
|
||||
'much is learnt, what is due, and the study time this week', (
|
||||
tester,
|
||||
) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final id = await card('japanese', '猫');
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Study plan']);
|
||||
|
||||
await PersonScope.of(
|
||||
tester.element(find.byType(StudysetScreen)),
|
||||
).reviews.record(
|
||||
cardId: id,
|
||||
rating: Rating.good,
|
||||
reviewedAt: clock.now(),
|
||||
duration: const Duration(minutes: 2),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(parts(tester), ['Material', 'Progress', 'Study plan']);
|
||||
|
||||
await openPart(tester, 'Progress');
|
||||
expect(find.text('Learned'), findsOneWidget);
|
||||
expect(find.text('0 of 1 card'), findsOneWidget);
|
||||
expect(find.text('To review'), findsOneWidget);
|
||||
expect(find.text('2 min'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('choosing another part ticks, as the navigation bar does; '
|
||||
'the one shown again does not', (tester) async {
|
||||
stored('japanese', 'Japanese');
|
||||
final haptics = <Object?>[];
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
(call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') {
|
||||
haptics.add(call.arguments);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
SystemChannels.platform,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await showApp(tester, location: RoutePaths.studyset('japanese'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await openPart(tester, 'Study plan');
|
||||
await openPart(tester, 'Material');
|
||||
await openPart(tester, 'Material');
|
||||
|
||||
expect(haptics, [
|
||||
'HapticFeedbackType.selectionClick',
|
||||
'HapticFeedbackType.selectionClick',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue