feat(learn): pick up learning on Home, the studysets studied latest first, and Progress closer to Vaia's

Home gets a widget of its own, Pick up learning, below the goal: the
studysets at the top of the library that were studied, the one rated last
first, so one studied long ago sinks. Three show, the others behind Show
more. Each shows its name, which opens it, how much is learnt, when it was
last studied, and today's task (cards to review, new cards to learn), which
starts studying it, outlined while reviews wait. The progress is the
library's, worked out once for every screen; when each was last studied
comes from the reviews Home reads anyway. With nothing studied it takes no
room.

A dashboard arranged before it gets the widget below the goal, wherever
the goal is: a widget a layout misses now goes after the one before it in
a new dashboard, no longer at the end.

On a studyset's Progress, the tiles are filled without an outline, To
review has a history icon (amber while cards are due), the learnt bar is
thicker, and the exam follows the figures, as Vaia asks for its date
there: an invitation to choose it, opening the study plan's sheet, or,
once chosen, how ready the person is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 09:03:05 +01:00
commit a0bb75f1ad
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 976 additions and 40 deletions

View file

@ -220,7 +220,12 @@ void main() {
(s) => s.goal != null && s.layout.hidden.isNotEmpty,
);
expect(state.layout.shown, [HomeWidget.week, HomeWidget.goal]);
// Pick up learning, which the layout missed, comes after the goal.
expect(state.layout.shown, [
HomeWidget.week,
HomeWidget.goal,
HomeWidget.pickUp,
]);
expect((await settings.read()).goal?.target, 50);
});
}

View file

@ -0,0 +1,71 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/pick_up.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/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
void main() {
Studyset studyset(String id, {String? parent, DateTime? archivedAt}) =>
Studyset(
id: id,
content: StudysetContent.titled(id),
parentId: parent,
archivedAt: archivedAt,
);
Review rated(String card, DateTime at, {StudyMode mode = StudyMode.review}) =>
Review(
id: '$card-$at',
cardId: card,
rating: Rating.good,
reviewedAt: at,
duration: Duration.zero,
mode: mode,
);
test('the studysets studied, the one studied last first, each counting '
'what was studied in its topics; one never studied is left out', () {
final studysets = [
studyset('japanese'),
studyset('kana', parent: 'japanese'),
studyset('latin'),
studyset('psychology'),
];
final picked = pickUpOf(
studysets,
{'neko': 'japanese', 'a': 'kana', 'amo': 'latin'},
[
rated('neko', DateTime.utc(2026)),
rated('amo', DateTime.utc(2026, 10)),
// Studied in a topic: the studyset counts as studied then.
rated('a', DateTime.utc(2026, 11)),
],
);
expect(
[for (final each in picked) each.studyset.id],
[
'japanese',
'latin',
],
);
expect(picked.first.lastStudied, DateTime.utc(2026, 11));
});
test('a fresh start is no studying, and an archived studyset rests', () {
final picked = pickUpOf(
[
studyset('japanese'),
studyset('latin', archivedAt: DateTime.utc(2026, 5)),
],
{'neko': 'japanese', 'amo': 'latin'},
[
rated('neko', DateTime.utc(2026, 11), mode: StudyMode.reset),
rated('amo', DateTime.utc(2026, 11)),
],
);
expect(picked, isEmpty);
});
}

View file

@ -7,6 +7,10 @@ import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/pomodoro/widget/pomodoro_card.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/widget/study_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -15,6 +19,7 @@ import 'package:material_ui/material_ui.dart';
import '../../../support/fake_card_gateway.dart';
import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart';
@ -22,8 +27,10 @@ void main() {
late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway;
late FakeSettingsGateway settingsGateway;
late FakeStudysetGateway studysetGateway;
setUp(() {
studysetGateway = FakeStudysetGateway();
cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway();
settingsGateway = FakeSettingsGateway();
@ -36,6 +43,7 @@ void main() {
await pumpApp(
tester,
testDependencies(
studysetGateway: studysetGateway,
cardGateway: cardGateway,
reviewGateway: reviewGateway,
settingsGateway: settingsGateway,
@ -46,22 +54,113 @@ void main() {
int streak(WidgetTester tester) =>
tester.widget<StreakChip>(find.byType(StreakChip)).streak;
/// Rates a new card now.
Future<void> rateToday() async {
/// Rates a new card in [studyset] at [at], now by default.
Future<void> rateToday({String studyset = 'japanese', DateTime? at}) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
final id = await cards.create(
'japanese',
studyset,
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
await ReviewRepository(reviewGateway).record(
cardId: id,
rating: Rating.good,
reviewedAt: DateTime.now(),
reviewedAt: at ?? DateTime.now(),
duration: Duration.zero,
);
}
/// The studyset [id], titled [title], on this device.
void stored(String id, String title) => studysetGateway.receiveRemoteChange(
id,
encodeStudysetPayload(StudysetContent.titled(title)),
);
/// A card never studied in [studyset].
Future<void> newCard(String studyset) async {
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
await cards.create(
studyset,
CardContent(front: RichContent.empty(), back: RichContent.empty()),
);
}
group('Pick up learning', () {
testWidgets('lists the studysets studied, the one studied last first, '
"with how much is learnt and today's task, which starts studying "
'it; one never studied is left out', (tester) async {
stored('japanese', 'Japanese');
stored('latin', 'Latin');
stored('psychology', 'Psychology');
await rateToday(
studyset: 'latin',
at: DateTime.now().subtract(const Duration(days: 3)),
);
await rateToday();
await newCard('japanese');
await newCard('psychology');
await showHome(tester);
await tester.pumpAndSettle();
expect(find.text('Pick up learning'), findsOneWidget);
expect(find.text('Psychology'), findsNothing);
expect(
tester.getTopLeft(find.text('Japanese')).dy,
lessThan(tester.getTopLeft(find.text('Latin')).dy),
);
expect(find.text('Studied today'), findsOneWidget);
expect(find.text('Studied 3 days ago'), findsOneWidget);
expect(find.text('Learn 1 new card'), findsOneWidget);
await tester.tap(find.text('Learn 1 new card'));
await tester.pumpAndSettle();
expect(find.byType(StudyScreen), findsOneWidget);
});
testWidgets("a studyset's name opens it", (tester) async {
stored('japanese', 'Japanese');
await rateToday();
await showHome(tester);
await tester.pumpAndSettle();
await tester.tap(find.text('Japanese'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);
});
testWidgets('shows three, the others behind Show more', (tester) async {
for (final (days, name) in [
(1, 'One'),
(2, 'Two'),
(3, 'Three'),
(4, 'Four'),
]) {
stored(name, name);
await rateToday(
studyset: name,
at: DateTime.now().subtract(Duration(days: days)),
);
}
await showHome(tester);
await tester.pumpAndSettle();
expect(find.text('Four'), findsNothing);
await tester.tap(find.text('Show 1 more'));
await tester.pumpAndSettle();
expect(find.text('Four'), findsOneWidget);
});
testWidgets('with nothing studied yet, it takes no room', (tester) async {
stored('japanese', 'Japanese');
await newCard('japanese');
await showHome(tester);
await tester.pumpAndSettle();
expect(find.text('Pick up learning'), findsNothing);
});
});
testWidgets('cobalt washes down from the top, behind a clear app bar', (
tester,
) async {
@ -246,6 +345,7 @@ void main() {
await tester.pumpAndSettle();
for (final name in [
'Daily goal',
'Pick up learning',
'Pomodoro',
'PDF reading deadlines',
'Week overview',

View file

@ -160,6 +160,7 @@ void main() {
order: [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.pickUp,
HomeWidget.pomodoro,
HomeWidget.deadlines,
HomeWidget.week,
@ -172,7 +173,8 @@ void main() {
});
test('a home layout from another version keeps the widgets this one '
'knows, and adds those it misses', () {
'knows, and adds those it misses, each after the one a new dashboard '
'has before it', () {
final settings = decodeSettingsPayload(
encodePayload(3, {
'studyTags': {},
@ -184,15 +186,39 @@ void main() {
);
expect(settings.home.order, [
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.pickUp,
HomeWidget.pomodoro,
HomeWidget.deadlines,
HomeWidget.week,
HomeWidget.activity,
]);
expect(settings.home.hidden, isEmpty);
});
test('a dashboard arranged before Pick up learning gets it below the '
'goal, wherever the goal is', () {
final settings = decodeSettingsPayload(
encodePayload(3, {
'studyTags': {},
'home': {
'order': ['week', 'activity', 'goal', 'pomodoro', 'deadlines'],
'hidden': ['pomodoro'],
},
}),
);
expect(settings.home.order, [
HomeWidget.week,
HomeWidget.activity,
HomeWidget.goal,
HomeWidget.pickUp,
HomeWidget.pomodoro,
HomeWidget.deadlines,
]);
expect(settings.home.hidden, {HomeWidget.pomodoro});
});
test('folding keeps both rest days, and the goal one of them has', () {
final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)});
final theirs = PersonSettings(

View file

@ -2051,6 +2051,35 @@ void main() {
expect(find.text('1 min'), findsOneWidget);
});
testWidgets('Progress invites choosing the exam below its figures, as '
'Vaia asks for its date; chosen, it shows how ready the person is', (
tester,
) async {
stored('japanese', 'Japanese');
final neko = await card('japanese', '猫');
await showApp(tester, location: RoutePaths.studyset('japanese'));
final person = PersonDependencies.of(
tester.element(find.byType(StudysetScreen)),
);
final n5 = await person.tags.create('JLPT-N5');
await person.tags.setTagsOf(neko, {n5});
await tester.pump(ExamCubit.settle);
await tester.pumpAndSettle();
await openPart(tester, 'Progress');
expect(find.text('Preparing for an exam?'), findsOneWidget);
await tester.tap(find.text('Choose the exam'));
await tester.pumpAndSettle();
await tester.tap(find.text('JLPT N5'));
await tester.pumpAndSettle();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Preparing for an exam?'), findsNothing);
expect(find.textContaining('In 84 days'), findsOneWidget);
expect(find.text('0 of 1 flashcard started'), findsOneWidget);
});
testWidgets('Progress also counts the steps of the learning paths done, '
'and lists the PDFs not read to their end, begun first, across the '
'topics; one opens where it is read', (tester) async {