feat(learn): the library on shelves by recency, narrowed to a subject (ADR 0041, D8)
The library as Vaia's (the developer's decisions of 10 Nov 2026): the starred on a shelf of their own, then those studied or changed in the last 7 days, the last month and before, each under its heading, the archive last. Recent is the later of a card's last rating (StudyProgress.lastStudied) and the studyset's own last change. A dropdown narrows the library to one of the subjects in use, kept on the device (DeviceSettings.librarySubject). A card names its subject under its title, with its icon, and a language course its language beside. Whats-New: The library groups your studysets by when you last used them, and narrows them to one subject. Whats-New-De: Die Bibliothek ordnet deine Lernsets danach, wann du sie zuletzt genutzt hast, und zeigt auf Wunsch nur ein Fach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9868dc27c6
commit
a3b124ddcb
5 changed files with 786 additions and 26 deletions
|
|
@ -65,6 +65,18 @@ void main() {
|
|||
expect(read.withLanguage('de').sidebarOpen, isTrue);
|
||||
});
|
||||
|
||||
test('keeps the subject the library is narrowed to (ADR 0041, D8), all '
|
||||
'until one is chosen', () async {
|
||||
expect((await store.read()).librarySubject, isNull);
|
||||
|
||||
await store.write(const DeviceSettings(librarySubject: 'languages'));
|
||||
|
||||
final read = await FileDeviceSettingsStore(file).read();
|
||||
expect(read.librarySubject, 'languages');
|
||||
expect(read.withLibrarySubject(null).librarySubject, isNull);
|
||||
expect(read.withLanguage('de').librarySubject, 'languages');
|
||||
});
|
||||
|
||||
test('shows the Pomodoro timer while studying until told not to', () async {
|
||||
expect((await store.read()).pomodoroWhileStudying, isTrue);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,46 @@ void main() {
|
|||
expect(progress.mastery, 0.25);
|
||||
});
|
||||
|
||||
test('names when a card among them was last rated, for the library to '
|
||||
"shelve by (the developer's decision of 10 Nov 2026)", () {
|
||||
CardSchedule ratedAt(DateTime at) => CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: now.add(const Duration(days: 2)),
|
||||
firstReviewedAt: at,
|
||||
lastReviewedAt: at,
|
||||
);
|
||||
final progress = studyProgress(
|
||||
groups: [
|
||||
['old', 'new'],
|
||||
['recent'],
|
||||
],
|
||||
schedules: {
|
||||
'old': ratedAt(DateTime.utc(2026, 9)),
|
||||
'new': fresh,
|
||||
'recent': ratedAt(DateTime.utc(2026, 10, 4)),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(progress.lastStudied, DateTime.utc(2026, 10, 4));
|
||||
expect(
|
||||
progress.withContent(notes: 1, paths: 0, files: 0).lastStudied,
|
||||
DateTime.utc(2026, 10, 4),
|
||||
);
|
||||
expect(
|
||||
studyProgress(
|
||||
groups: [
|
||||
['new'],
|
||||
],
|
||||
schedules: {'new': fresh},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
).lastStudied,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts only as many new cards as today still starts', () {
|
||||
final order = [for (var i = 0; i < 30; i++) 'n$i'];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/library_shelves.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
|
||||
/// The library as Vaia's (ADR 0041, D8, the developer's decisions of 10 Nov
|
||||
/// 2026): the starred on top, then by when each was last studied or changed,
|
||||
/// in the last 7 days, the last month and before; narrowed to a subject.
|
||||
void main() {
|
||||
final now = DateTime.utc(2026, 11, 10, 12);
|
||||
|
||||
Studyset studyset(
|
||||
String id, {
|
||||
DateTime? updatedAt,
|
||||
bool starred = false,
|
||||
String? subject,
|
||||
}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
subject: subject,
|
||||
),
|
||||
updatedAt: updatedAt,
|
||||
starredAt: starred ? DateTime.utc(2026) : null,
|
||||
);
|
||||
|
||||
StudyProgress studied(DateTime at) => StudyProgress(
|
||||
cards: 1,
|
||||
due: 0,
|
||||
reviewsDue: 0,
|
||||
mature: 0,
|
||||
lastStudied: at,
|
||||
);
|
||||
|
||||
test('shelves by the later of the last rating and the last change, the '
|
||||
'most recent first in each shelf', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
studyset('changed-today', updatedAt: now),
|
||||
studyset('studied-3-days-ago', updatedAt: DateTime.utc(2026, 1, 2)),
|
||||
studyset('changed-2-weeks-ago', updatedAt: DateTime.utc(2026, 10, 27)),
|
||||
studyset('old', updatedAt: DateTime.utc(2026, 1, 2)),
|
||||
studyset('yesterday', updatedAt: now.subtract(const Duration(days: 1))),
|
||||
],
|
||||
progress: {
|
||||
'studied-3-days-ago': studied(now.subtract(const Duration(days: 3))),
|
||||
'old': studied(DateTime.utc(2026, 2)),
|
||||
},
|
||||
now: now,
|
||||
);
|
||||
|
||||
List<String> ids(LibraryShelf shelf) => [
|
||||
for (final studyset in shelves[shelf] ?? const <Studyset>[]) studyset.id,
|
||||
];
|
||||
expect(ids(LibraryShelf.lastWeek), [
|
||||
'changed-today',
|
||||
'yesterday',
|
||||
'studied-3-days-ago',
|
||||
]);
|
||||
expect(ids(LibraryShelf.lastMonth), ['changed-2-weeks-ago']);
|
||||
expect(ids(LibraryShelf.older), ['old']);
|
||||
expect(shelves.keys, isNot(contains(LibraryShelf.starred)));
|
||||
});
|
||||
|
||||
test('the starred stand on their own shelf on top, however long ago', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
studyset('recent', updatedAt: now),
|
||||
studyset('starred-old', updatedAt: DateTime.utc(2025), starred: true),
|
||||
],
|
||||
progress: const {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(shelves.keys.first, LibraryShelf.starred);
|
||||
expect(shelves[LibraryShelf.starred]!.single.id, 'starred-old');
|
||||
expect(shelves[LibraryShelf.lastWeek]!.single.id, 'recent');
|
||||
});
|
||||
|
||||
test('a studyset from before changes were recorded counts from when it '
|
||||
'was made, its UUID v7 saying when', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
// Made at 2026-11-08 00:00 UTC.
|
||||
studyset('01a1b874-6c00-7000-8000-000000000000'),
|
||||
studyset('not-a-uuid'),
|
||||
],
|
||||
progress: const {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(shelves[LibraryShelf.lastWeek]!.single.id, startsWith('01a1'));
|
||||
expect(shelves[LibraryShelf.older]!.single.id, 'not-a-uuid');
|
||||
});
|
||||
|
||||
test("the subjects in use, in the list's order; one without a subject, "
|
||||
'or one this app does not know, counts as Other', () {
|
||||
final studysets = [
|
||||
studyset('rust', subject: 'software-engineering'),
|
||||
studyset('japanese', subject: 'languages'),
|
||||
studyset('notes'),
|
||||
studyset('origami', subject: 'paper-folding'),
|
||||
studyset('korean', subject: 'languages'),
|
||||
];
|
||||
|
||||
expect(subjectsInUse(studysets), [
|
||||
Subject.softwareEngineering,
|
||||
Subject.languages,
|
||||
Subject.other,
|
||||
]);
|
||||
expect(
|
||||
[
|
||||
for (final studyset in studysets)
|
||||
if (filedUnder(studyset, Subject.other)) studyset.id,
|
||||
],
|
||||
['notes', 'origami'],
|
||||
);
|
||||
expect(filedUnder(studysets.first, Subject.softwareEngineering), isTrue);
|
||||
expect(filedUnder(studysets.first, Subject.languages), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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';
|
||||
|
|
@ -22,6 +23,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_publish_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_device_settings_store.dart';
|
||||
import '../../../support/memory_published_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
|
@ -76,6 +78,7 @@ void main() {
|
|||
DateTime? starredAt,
|
||||
String? publishedAt,
|
||||
String? subject,
|
||||
DateTime? updatedAt,
|
||||
bool private = false,
|
||||
}) => gateway.receiveRemoteChange(
|
||||
id,
|
||||
|
|
@ -93,6 +96,7 @@ void main() {
|
|||
),
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: private,
|
||||
);
|
||||
|
||||
|
|
@ -373,6 +377,133 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("shelved and narrowed as Vaia's library (ADR 0041, D8, the "
|
||||
"developer's decisions of 10 Nov 2026)", () {
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
testWidgets('the starred on top, then the last 7 days, the last month '
|
||||
'and before, each under its heading; the archive last', (
|
||||
tester,
|
||||
) async {
|
||||
stored('old', 'Old', updatedAt: daysAgo(90));
|
||||
stored('recent', 'Recent', updatedAt: daysAgo(1));
|
||||
stored('weeks', 'Weeks', updatedAt: daysAgo(14));
|
||||
stored('star', 'Star', updatedAt: daysAgo(200), starredAt: daysAgo(1));
|
||||
stored('rest', 'Rest', updatedAt: daysAgo(2), archivedAt: daysAgo(2));
|
||||
await showLibrary(tester);
|
||||
double topOf(Finder finder) => tester.getTopLeft(finder).dy;
|
||||
|
||||
final order = [
|
||||
'Starred',
|
||||
'Star',
|
||||
'Last 7 days',
|
||||
'Recent',
|
||||
'Last month',
|
||||
'Weeks',
|
||||
'Older',
|
||||
'Old',
|
||||
'Archived',
|
||||
];
|
||||
for (final (before, after) in [
|
||||
for (var i = 1; i < order.length; i++) (order[i - 1], order[i]),
|
||||
]) {
|
||||
expect(
|
||||
topOf(find.text(before).first),
|
||||
lessThan(topOf(find.text(after).first)),
|
||||
reason: '$before above $after',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the dropdown offers the subjects in use, narrows the '
|
||||
'library to one, and the device keeps it', (tester) async {
|
||||
final settings = MemoryDeviceSettingsStore();
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
stored('japanese', 'Japanese', subject: 'languages');
|
||||
stored('notes', 'Notes');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettingsStore: settings,
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('All subjects'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.widgetWithText(MenuItemButton, 'Software engineering'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(MenuItemButton, 'Other / not sure'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Biology'), findsNothing, reason: 'not in use');
|
||||
await tester.tap(find.widgetWithText(MenuItemButton, 'Languages'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsNothing);
|
||||
expect(find.widgetWithText(StudysetCard, 'Notes'), findsNothing);
|
||||
expect(settings.writes.last.librarySubject, 'languages');
|
||||
|
||||
await tester.tap(find.byTooltip('Show one subject'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(MenuItemButton, 'Other / not sure'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(StudysetCard, 'Notes'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('opens on the subject kept', (
|
||||
tester,
|
||||
) async {
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
stored('japanese', 'Japanese', subject: 'languages');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettings: const DeviceSettings(librarySubject: 'languages'),
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsNothing);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a subject kept but no longer in use shows all', (
|
||||
tester,
|
||||
) async {
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettings: const DeviceSettings(librarySubject: 'biology'),
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsOneWidget);
|
||||
expect(find.text('All subjects'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('creating waits for a title', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
|
|
@ -482,9 +613,10 @@ void main() {
|
|||
|
||||
await showLibrary(tester);
|
||||
|
||||
// On a shelf of their own above (ADR 0041, D8).
|
||||
expect(
|
||||
tester.getTopLeft(find.text('German')).dx,
|
||||
lessThan(tester.getTopLeft(find.text('Japanese')).dx),
|
||||
tester.getTopLeft(find.text('German')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Japanese')).dy),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ void main() {
|
|||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 188,
|
||||
height: 212,
|
||||
child: StudysetCard(
|
||||
studyset: studyset ?? vocabulary,
|
||||
progress: progress,
|
||||
|
|
@ -77,6 +78,47 @@ void main() {
|
|||
expect(find.text('52 flashcards'), findsOneWidget);
|
||||
});
|
||||
|
||||
group("names its subject under the title (ADR 0041, D8, the developer's "
|
||||
'decisions of 10 Nov 2026)', () {
|
||||
Studyset filed(String? subject, {String? front}) => Studyset(
|
||||
id: 'set',
|
||||
content: StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
subject: subject,
|
||||
frontLanguage: front,
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('with its icon', (tester) async {
|
||||
await show(tester, studyset: filed('software-engineering'));
|
||||
|
||||
final name = find.text('Software engineering');
|
||||
expect(name, findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(name).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Japanese')).dy - 1),
|
||||
);
|
||||
expect(find.byIcon(Subject.softwareEngineering.icon), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a language course with its language', (tester) async {
|
||||
await show(tester, studyset: filed('languages', front: 'ja'));
|
||||
|
||||
expect(find.text('Languages · Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('none, nor one this app does not know: nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, studyset: filed(null));
|
||||
expect(find.byIcon(Subject.other.icon), findsNothing);
|
||||
|
||||
await show(tester, studyset: filed('paper-folding'));
|
||||
expect(find.byIcon(Subject.other.icon), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('says when a studyset holds nothing yet', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
|
|
|
|||
Loading…
Reference in a new issue