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
17 changed files with 976 additions and 40 deletions

View file

@ -1380,6 +1380,16 @@
"readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.", "readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.",
"readerScreenshotSaved": "Bildschirmfoto gespeichert", "readerScreenshotSaved": "Bildschirmfoto gespeichert",
"imageOpenPdfPage": "PDF auf dieser Seite öffnen", "imageOpenPdfPage": "PDF auf dieser Seite öffnen",
"homeWidgetPickUp": "Weiterlernen",
"homePickUpPlaceholder": "Die Lernsets, die du lernst, das zuletzt gelernte zuerst",
"homePickUpStudied": "{days, plural, =0{Heute gelernt} =1{Gestern gelernt} other{Vor {days} Tagen gelernt}}",
"homePickUpLearned": "{percent} % gelernt",
"homePickUpReview": "{count, plural, =1{1 Karte wiederholen} other{{count} Karten wiederholen}}",
"homePickUpLearn": "{count, plural, =1{1 neue Karte lernen} other{{count} neue Karten lernen}}",
"homePickUpReviewAndLearn": "{reviews} wiederholen, {fresh} neue lernen",
"homePickUpDone": "Heute nichts fällig",
"homePickUpMore": "{count} weitere zeigen",
"homePickUpFewer": "Weniger zeigen",
"homeWidgetDeadlines": "PDF-Lesefristen", "homeWidgetDeadlines": "PDF-Lesefristen",
"deadlinesTitle": "Lesefristen", "deadlinesTitle": "Lesefristen",
"deadlinesCurrent": "Aktuell", "deadlinesCurrent": "Aktuell",
@ -1953,6 +1963,9 @@
"progressToReview": "Zu wiederholen", "progressToReview": "Zu wiederholen",
"progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.", "progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.",
"progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}", "progressDueToday": "{count, plural, =1{Karte heute fällig} other{Karten heute fällig}}",
"progressExamTitle": "Bereitest du dich auf eine Prüfung vor?",
"progressExamHint": "Wähle sie und ihr Datum, um zu sehen, wie bereit du bist.",
"progressExamChoose": "Prüfung wählen",
"progressStudyTime": "Lernzeit", "progressStudyTime": "Lernzeit",
"progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen gelernt hast, jede Karte höchstens eine Minute gezählt.", "progressStudyTimeInfo": "Wie lange du die Karteikarten dieses Lernsets in den letzten sieben Tagen gelernt hast, jede Karte höchstens eine Minute gezählt.",
"progressThisWeek": "diese Woche", "progressThisWeek": "diese Woche",

View file

@ -1577,6 +1577,22 @@
"readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.", "readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.",
"readerScreenshotSaved": "Screenshot saved", "readerScreenshotSaved": "Screenshot saved",
"imageOpenPdfPage": "Open the PDF at this page", "imageOpenPdfPage": "Open the PDF at this page",
"homeWidgetPickUp": "Pick up learning",
"homePickUpPlaceholder": "The studysets you study, the latest first",
"homePickUpStudied": "{days, plural, =0{Studied today} =1{Studied yesterday} other{Studied {days} days ago}}",
"@homePickUpStudied": {"placeholders": {"days": {"type": "int"}}},
"homePickUpLearned": "{percent} % learned",
"@homePickUpLearned": {"placeholders": {"percent": {"type": "int"}}},
"homePickUpReview": "{count, plural, =1{Review 1 card} other{Review {count} cards}}",
"@homePickUpReview": {"placeholders": {"count": {"type": "int"}}},
"homePickUpLearn": "{count, plural, =1{Learn 1 new card} other{Learn {count} new cards}}",
"@homePickUpLearn": {"placeholders": {"count": {"type": "int"}}},
"homePickUpReviewAndLearn": "Review {reviews}, learn {fresh} new",
"@homePickUpReviewAndLearn": {"placeholders": {"reviews": {"type": "int"}, "fresh": {"type": "int"}}},
"homePickUpDone": "Nothing due today",
"homePickUpMore": "Show {count} more",
"@homePickUpMore": {"placeholders": {"count": {"type": "int"}}},
"homePickUpFewer": "Show fewer",
"homeWidgetDeadlines": "PDF reading deadlines", "homeWidgetDeadlines": "PDF reading deadlines",
"deadlinesTitle": "Reading deadlines", "deadlinesTitle": "Reading deadlines",
"deadlinesCurrent": "Current", "deadlinesCurrent": "Current",
@ -2329,6 +2345,9 @@
"progressToReviewInfo": "Cards studied before that are due again today.", "progressToReviewInfo": "Cards studied before that are due again today.",
"progressDueToday": "{count, plural, =1{card due today} other{cards due today}}", "progressDueToday": "{count, plural, =1{card due today} other{cards due today}}",
"@progressDueToday": {"placeholders": {"count": {"type": "int"}}}, "@progressDueToday": {"placeholders": {"count": {"type": "int"}}},
"progressExamTitle": "Preparing for an exam?",
"progressExamHint": "Choose it and its date to see how ready you are.",
"progressExamChoose": "Choose the exam",
"progressStudyTime": "Study time", "progressStudyTime": "Study time",
"progressStudyTimeInfo": "How long you studied this studyset's flashcards in the last seven days, each card counted for at most a minute.", "progressStudyTimeInfo": "How long you studied this studyset's flashcards in the last seven days, each card counted for at most a minute.",
"progressThisWeek": "this week", "progressThisWeek": "this week",

View file

@ -8140,6 +8140,66 @@ abstract class AppLocalizations {
/// **'Open the PDF at this page'** /// **'Open the PDF at this page'**
String get imageOpenPdfPage; String get imageOpenPdfPage;
/// No description provided for @homeWidgetPickUp.
///
/// In en, this message translates to:
/// **'Pick up learning'**
String get homeWidgetPickUp;
/// No description provided for @homePickUpPlaceholder.
///
/// In en, this message translates to:
/// **'The studysets you study, the latest first'**
String get homePickUpPlaceholder;
/// No description provided for @homePickUpStudied.
///
/// In en, this message translates to:
/// **'{days, plural, =0{Studied today} =1{Studied yesterday} other{Studied {days} days ago}}'**
String homePickUpStudied(int days);
/// No description provided for @homePickUpLearned.
///
/// In en, this message translates to:
/// **'{percent} % learned'**
String homePickUpLearned(int percent);
/// No description provided for @homePickUpReview.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Review 1 card} other{Review {count} cards}}'**
String homePickUpReview(int count);
/// No description provided for @homePickUpLearn.
///
/// In en, this message translates to:
/// **'{count, plural, =1{Learn 1 new card} other{Learn {count} new cards}}'**
String homePickUpLearn(int count);
/// No description provided for @homePickUpReviewAndLearn.
///
/// In en, this message translates to:
/// **'Review {reviews}, learn {fresh} new'**
String homePickUpReviewAndLearn(int reviews, int fresh);
/// No description provided for @homePickUpDone.
///
/// In en, this message translates to:
/// **'Nothing due today'**
String get homePickUpDone;
/// No description provided for @homePickUpMore.
///
/// In en, this message translates to:
/// **'Show {count} more'**
String homePickUpMore(int count);
/// No description provided for @homePickUpFewer.
///
/// In en, this message translates to:
/// **'Show fewer'**
String get homePickUpFewer;
/// No description provided for @homeWidgetDeadlines. /// No description provided for @homeWidgetDeadlines.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -11534,6 +11594,24 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{card due today} other{cards due today}}'** /// **'{count, plural, =1{card due today} other{cards due today}}'**
String progressDueToday(int count); String progressDueToday(int count);
/// No description provided for @progressExamTitle.
///
/// In en, this message translates to:
/// **'Preparing for an exam?'**
String get progressExamTitle;
/// No description provided for @progressExamHint.
///
/// In en, this message translates to:
/// **'Choose it and its date to see how ready you are.'**
String get progressExamHint;
/// No description provided for @progressExamChoose.
///
/// In en, this message translates to:
/// **'Choose the exam'**
String get progressExamChoose;
/// No description provided for @progressStudyTime. /// No description provided for @progressStudyTime.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -5006,6 +5006,68 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get imageOpenPdfPage => 'PDF auf dieser Seite öffnen'; String get imageOpenPdfPage => 'PDF auf dieser Seite öffnen';
@override
String get homeWidgetPickUp => 'Weiterlernen';
@override
String get homePickUpPlaceholder =>
'Die Lernsets, die du lernst, das zuletzt gelernte zuerst';
@override
String homePickUpStudied(int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: 'Vor $days Tagen gelernt',
one: 'Gestern gelernt',
zero: 'Heute gelernt',
);
return '$_temp0';
}
@override
String homePickUpLearned(int percent) {
return '$percent % gelernt';
}
@override
String homePickUpReview(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karten wiederholen',
one: '1 Karte wiederholen',
);
return '$_temp0';
}
@override
String homePickUpLearn(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count neue Karten lernen',
one: '1 neue Karte lernen',
);
return '$_temp0';
}
@override
String homePickUpReviewAndLearn(int reviews, int fresh) {
return '$reviews wiederholen, $fresh neue lernen';
}
@override
String get homePickUpDone => 'Heute nichts fällig';
@override
String homePickUpMore(int count) {
return '$count weitere zeigen';
}
@override
String get homePickUpFewer => 'Weniger zeigen';
@override @override
String get homeWidgetDeadlines => 'PDF-Lesefristen'; String get homeWidgetDeadlines => 'PDF-Lesefristen';
@ -7301,6 +7363,16 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get progressExamTitle => 'Bereitest du dich auf eine Prüfung vor?';
@override
String get progressExamHint =>
'Wähle sie und ihr Datum, um zu sehen, wie bereit du bist.';
@override
String get progressExamChoose => 'Prüfung wählen';
@override @override
String get progressStudyTime => 'Lernzeit'; String get progressStudyTime => 'Lernzeit';

View file

@ -4959,6 +4959,68 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get imageOpenPdfPage => 'Open the PDF at this page'; String get imageOpenPdfPage => 'Open the PDF at this page';
@override
String get homeWidgetPickUp => 'Pick up learning';
@override
String get homePickUpPlaceholder =>
'The studysets you study, the latest first';
@override
String homePickUpStudied(int days) {
String _temp0 = intl.Intl.pluralLogic(
days,
locale: localeName,
other: 'Studied $days days ago',
one: 'Studied yesterday',
zero: 'Studied today',
);
return '$_temp0';
}
@override
String homePickUpLearned(int percent) {
return '$percent % learned';
}
@override
String homePickUpReview(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Review $count cards',
one: 'Review 1 card',
);
return '$_temp0';
}
@override
String homePickUpLearn(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Learn $count new cards',
one: 'Learn 1 new card',
);
return '$_temp0';
}
@override
String homePickUpReviewAndLearn(int reviews, int fresh) {
return 'Review $reviews, learn $fresh new';
}
@override
String get homePickUpDone => 'Nothing due today';
@override
String homePickUpMore(int count) {
return 'Show $count more';
}
@override
String get homePickUpFewer => 'Show fewer';
@override @override
String get homeWidgetDeadlines => 'PDF reading deadlines'; String get homeWidgetDeadlines => 'PDF reading deadlines';
@ -7233,6 +7295,16 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String get progressExamTitle => 'Preparing for an exam?';
@override
String get progressExamHint =>
'Choose it and its date to see how ready you are.';
@override
String get progressExamChoose => 'Choose the exam';
@override @override
String get progressStudyTime => 'Study time'; String get progressStudyTime => 'Study time';

View file

@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/pick_up.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_goal.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart';
@ -17,8 +18,9 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// The home dashboard: the streak, the study goal and how far it got, the /// The home dashboard: the streak, the study goal and how far it got, the
/// week with its rest days and the cards falling due, the activity of the /// studysets to pick up learning in, the week with its rest days and the
/// past year, and how the person arranged it. It follows every rating, card, /// cards falling due, the activity of the past year, and how the person
/// arranged it. It follows every rating, card,
/// studyset and setting, here or synced. /// studyset and setting, here or synced.
class HomeBloc( class HomeBloc(
final StudysetRepository _studysets, final StudysetRepository _studysets,
@ -144,6 +146,9 @@ class HomeBloc(
if (!resting.contains(card.studysetId)) card.id, if (!resting.contains(card.studysetId)) card.id,
]; ];
final schedules = _reviews.schedulesFrom(reviews, active); final schedules = _reviews.schedulesFrom(reviews, active);
final pickUp = pickUpOf(notDeleted(everything), {
for (final card in cards) card.id: card.studysetId,
}, reviews);
if (refresh != _refreshes) { if (refresh != _refreshes) {
return; return;
} }
@ -157,6 +162,7 @@ class HomeBloc(
due: dueByDay([ due: dueByDay([
for (final schedule in schedules.values) ?schedule.due, for (final schedule in schedules.values) ?schedule.due,
], localDay(now)), ], localDay(now)),
pickUp: pickUp,
goal: settings.goal, goal: settings.goal,
goalProgress: switch (settings.goal) { goalProgress: switch (settings.goal) {
final goal? => goalProgress(goal, ratings, now), final goal? => goalProgress(goal, ratings, now),
@ -245,6 +251,9 @@ final class const HomeStateLoaded({
/// How many cards fall due on each day. /// How many cards fall due on each day.
required final Map<DateTime, int> due, required final Map<DateTime, int> due,
/// The studysets to pick up learning in, the one studied last first.
required final List<PickUp> pickUp,
/// The study goal; null without one. /// The study goal; null without one.
required final StudyGoal? goal, required final StudyGoal? goal,
@ -261,6 +270,7 @@ final class const HomeStateLoaded({
restDays, restDays,
streak, streak,
due, due,
pickUp,
goal, goal,
goalProgress, goalProgress,
layout, layout,

View file

@ -5,6 +5,9 @@ enum HomeWidget() {
/// The study goal. /// The study goal.
goal, goal,
/// The studysets studied, the latest first, to pick up learning in.
pickUp,
/// The Pomodoro timer. /// The Pomodoro timer.
pomodoro, pomodoro,
@ -29,16 +32,23 @@ final class const HomeLayout({
final Set<HomeWidget> hidden = const {}, final Set<HomeWidget> hidden = const {},
}) with Equatable { }) with Equatable {
/// The layout with [order] (widgets a newer version knows dropped, /// The layout with [order] (widgets a newer version knows dropped,
/// widgets it misses added at the end) and [hidden]. /// widgets it misses added each after the one before it in a new
factory reconciled(List<HomeWidget> order, Set<HomeWidget> hidden) => /// dashboard, so that a widget new in this version shows where a new
HomeLayout( /// dashboard has it, not below everything) and [hidden].
order: [ factory reconciled(List<HomeWidget> order, Set<HomeWidget> hidden) {
...{...order}, final reconciled = [
for (final widget in HomeWidget.values) ...{...order},
if (!order.contains(widget)) widget, ];
], for (final (index, widget) in HomeWidget.values.indexed) {
hidden: hidden, if (!reconciled.contains(widget)) {
); final before = index == 0
? -1
: reconciled.indexOf(HomeWidget.values[index - 1]);
reconciled.insert(before + 1, widget);
}
}
return HomeLayout(order: reconciled, hidden: hidden);
}
/// The widgets shown, top to bottom. /// The widgets shown, top to bottom.
List<HomeWidget> get shown => [ List<HomeWidget> get shown => [

View file

@ -0,0 +1,57 @@
import 'package:equatable/equatable.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_tree.dart';
/// A studyset to pick up learning in, on Home (decided 2026-11-02, after
/// Vaia's): one of the person's own at the top of the library, and when a
/// card in it or in its topics was last rated.
final class const PickUp({
/// The studyset.
required final Studyset studyset,
/// When a card in it was last rated, in UTC.
required final DateTime lastStudied,
}) with Equatable {
@override
List<Object?> get props => [studyset, lastStudied];
}
/// The studysets at the top of [studysets] (none deleted) that were
/// studied, the one studied last first: rated in [reviews], a card counted
/// in the studyset [studysetOfCard] gives it and in the studyset at the
/// top above that. A fresh start of a card is no studying, and an archived
/// studyset rests, as it falls due nowhere.
List<PickUp> pickUpOf(
List<Studyset> studysets,
Map<String, String> studysetOfCard,
Iterable<Review> reviews,
) {
final tops = {
for (final studyset in topLevel(studysets))
if (!studyset.isArchived) studyset.id: studyset,
};
final topOf = <String, String>{
for (final top in tops.keys)
for (final id in subtreeOf(top, studysets)) id: top,
};
final last = <String, DateTime>{};
for (final review in reviews) {
if (!review.mode.rates) {
continue;
}
final top = topOf[studysetOfCard[review.cardId]];
if (top == null) {
continue;
}
final at = review.reviewedAt;
if (last[top] case final before? when !at.isAfter(before)) {
continue;
}
last[top] = at;
}
return [
for (final MapEntry(key: id, value: at) in last.entries)
PickUp(studyset: tops[id]!, lastStudied: at),
]..sort((a, b) => b.lastStudied.compareTo(a.lastStudied));
}

View file

@ -12,6 +12,7 @@ import 'package:halkyon_learn_flutter/src/feature/home/model/home_layout.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart'; import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/activity_heatmap.dart'; import 'package:halkyon_learn_flutter/src/feature/home/widget/activity_heatmap.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart'; import 'package:halkyon_learn_flutter/src/feature/home/widget/goal_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/pick_up_card.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart'; import 'package:halkyon_learn_flutter/src/feature/home/widget/streak.dart';
import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart'; import 'package:halkyon_learn_flutter/src/feature/home/widget/week_overview.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_deadlines_card.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/widget/reading_deadlines_card.dart';
@ -21,7 +22,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The home tab, where the app opens: a study dashboard. The streak sits in /// The home tab, where the app opens: a study dashboard. The streak sits in
/// the app bar; below it the study goal, the week and a year of activity, /// the app bar; below it the study goal, the studysets to pick up learning
/// in, the week and a year of activity,
/// in the order the person arranged them. Cobalt washes down from the top /// in the order the person arranged them. Cobalt washes down from the top
/// ([TopGradient]). Where the person searches the web or asks an assistant /// ([TopGradient]). Where the person searches the web or asks an assistant
/// from the app, its button stands at the lower left ([AssistantButton]). /// from the app, its button stands at the lower left ([AssistantButton]).
@ -199,6 +201,11 @@ class _HomeState() extends State<_Home> {
progress: state.goalProgress, progress: state.goalProgress,
onChanged: (goal) => _send(HomeEventGoalSet(goal)), onChanged: (goal) => _send(HomeEventGoalSet(goal)),
), ),
HomeWidget.pickUp => PickUpCard(
pickUp: state.pickUp,
now: state.now,
placeholder: _arranging,
),
HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging), HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging),
HomeWidget.week => Card( HomeWidget.week => Card(
child: Padding( child: Padding(
@ -255,9 +262,11 @@ class _HomeState() extends State<_Home> {
// Taps still reach the widget's own controls. // Taps still reach the widget's own controls.
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onLongPress: _arrange, onLongPress: _arrange,
// The reading deadlines leave no gap while they show // The reading deadlines and Pick up learning leave no
// nothing: they keep their own. // gap while they show nothing: they keep their own.
child: widget == HomeWidget.deadlines child:
widget == HomeWidget.deadlines ||
widget == HomeWidget.pickUp
? section(widget) ? section(widget)
: Padding( : Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
@ -328,6 +337,7 @@ class const _Arranging({
String label(HomeWidget widget) => switch (widget) { String label(HomeWidget widget) => switch (widget) {
HomeWidget.pomodoro => l10n.pomodoroTitle, HomeWidget.pomodoro => l10n.pomodoroTitle,
HomeWidget.goal => l10n.homeWidgetGoal, HomeWidget.goal => l10n.homeWidgetGoal,
HomeWidget.pickUp => l10n.homeWidgetPickUp,
HomeWidget.deadlines => l10n.homeWidgetDeadlines, HomeWidget.deadlines => l10n.homeWidgetDeadlines,
HomeWidget.week => l10n.homeWidgetWeek, HomeWidget.week => l10n.homeWidgetWeek,
HomeWidget.activity => l10n.homeWidgetActivity, HomeWidget.activity => l10n.homeWidgetActivity,

View file

@ -0,0 +1,265 @@
import 'dart:async';
import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/pick_up.dart';
import 'package:halkyon_learn_flutter/src/feature/home/model/study_activity.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Home's Pick up learning (decided 2026-11-02, after Vaia's): the
/// studysets studied, the one studied last first, so that one studied long
/// ago sinks; [shown] of them, the others behind Show more. Each with its
/// name opening it, how much of it is learnt, when it was last studied,
/// and today's task, which starts studying it. The progress is the
/// library's, worked out once for every screen
/// (`StudysetProgressRepository`). With nothing studied it takes no room,
/// but while the dashboard is arranged ([placeholder]).
class const PickUpCard({
required final List<PickUp> pickUp,
required final DateTime now,
final bool placeholder = false,
super.key,
}) extends StatefulWidget {
/// How many show before Show more.
static const shown = 3;
@override
State<PickUpCard> createState() => _PickUpCardState();
}
class _PickUpCardState() extends State<PickUpCard> {
late final _progress = PersonDependencies.of(
context,
).studysetProgress.watch();
var _all = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
if (widget.pickUp.isEmpty) {
return widget.placeholder
? Card(
child: ListTile(
leading: const Icon(LucideIcons.bookOpenCheck),
title: Text(l10n.homeWidgetPickUp),
subtitle: Text(l10n.homePickUpPlaceholder),
),
)
: const SizedBox.shrink();
}
final listed = _all
? widget.pickUp
: widget.pickUp.take(PickUpCard.shown).toList();
final more = widget.pickUp.length - PickUpCard.shown;
return StreamBuilder<Map<String, StudyProgress>>(
stream: _progress,
builder: (context, snapshot) => Padding(
// The gap below, kept here as the reading deadlines keep theirs.
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(
l10n.homeWidgetPickUp,
style: theme.textTheme.titleMedium,
),
),
for (final (index, each) in listed.indexed) ...[
if (index > 0) const SizedBox(height: 12),
_Studyset(
pickUp: each,
progress: snapshot.data?[each.studyset.id],
now: widget.now,
),
],
if (more > 0)
Align(
child: TextButton(
onPressed: () => setState(() => _all = !_all),
child: Text(
_all ? l10n.homePickUpFewer : l10n.homePickUpMore(more),
),
),
),
],
),
),
);
}
}
/// One studyset to pick up: its name opening it, its learnt share as a
/// bar, when it was last studied, and today's task.
class const _Studyset({
required final PickUp pickUp,
required final StudyProgress? progress,
required final DateTime now,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final studyset = pickUp.studyset;
final color =
PaletteColor.byName(studyset.content.colorName)?.background ??
colors.primary;
final muted = theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
);
final today = localDay(now);
final lastDay = localDay(pickUp.lastStudied.toLocal());
// Rounded: a day with a clock change is 23 or 25 hours long.
final daysAgo = (today.difference(lastDay).inHours / 24).round();
final learned = progress?.mastery ?? 0;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () =>
unawaited(context.push(RoutePaths.studyset(studyset.id))),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Flexible(
child: Text(
studyset.content.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
),
const SizedBox(width: 4),
const Icon(LucideIcons.chevronRight, size: 18),
],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LinearProgressIndicator(
value: learned,
minHeight: 6,
borderRadius: BorderRadius.circular(3),
color: color,
backgroundColor: color.withValues(alpha: 0.2),
stopIndicatorRadius: 0,
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Text(
l10n.homePickUpStudied(daysAgo < 0 ? 0 : daysAgo),
style: muted,
),
),
Text(
l10n.homePickUpLearned((learned * 100).round()),
style: muted,
),
],
),
const SizedBox(height: 10),
_Task(studysetId: studyset.id, progress: progress),
],
),
),
],
),
),
);
}
}
/// Today's task in a studyset: its reviews and new cards, which starts
/// studying it, outlined while reviews wait; or that nothing is due.
class const _Task({
required final String studysetId,
required final StudyProgress? progress,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
final progress = this.progress;
if (progress == null) {
return const SizedBox(height: 48);
}
final reviews = progress.reviewsDue;
final fresh = progress.due - reviews;
if (reviews == 0 && fresh == 0) {
return Row(
children: [
Icon(LucideIcons.circleCheck, size: 18, color: theme.extras.success),
const SizedBox(width: 8),
Text(
l10n.homePickUpDone,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
);
}
final (icon, text) = switch ((reviews, fresh)) {
(> 0, > 0) => (
LucideIcons.glasses,
l10n.homePickUpReviewAndLearn(reviews, fresh),
),
(> 0, _) => (LucideIcons.glasses, l10n.homePickUpReview(reviews)),
_ => (LucideIcons.play, l10n.homePickUpLearn(fresh)),
};
return Material(
color: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
// Reviews waiting stand out, as their cards slip while they wait.
side: reviews > 0
? BorderSide(color: colors.primary, width: 1.5)
: BorderSide.none,
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => unawaited(_study(context)),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colors.primary),
const SizedBox(width: 12),
Expanded(child: Text(text, style: theme.textTheme.bodyLarge)),
const Icon(LucideIcons.chevronRight, size: 18),
],
),
),
),
);
}
Future<void> _study(BuildContext context) async {
final progress = PersonDependencies.of(context).studysetProgress;
await startStudying(context, studysetId);
// The session's reviews changed what is due.
progress.refresh();
}
}

View file

@ -183,6 +183,86 @@ class const ExamSection({required final String studysetId, super.key})
} }
} }
/// The exam on a studyset's Progress (decided 2026-11-02, after Vaia's,
/// which asks for the exam's date below its figures): with one chosen,
/// [ExamSection]'s view of how ready the person is for it; without one, an
/// invitation to choose it, opening the same sheet as the study plan's
/// row. Nothing while the plans are read.
class const ProgressExam({required final String studysetId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final state = context.watch<ExamCubit>().state;
if (state.plans == null) {
return const SizedBox.shrink();
}
if (state.choice != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ExamSection(studysetId: studysetId),
);
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: DecoratedBox(
decoration: BoxDecoration(
// Tinted, as Vaia's stands out from the figures above it.
color: Color.alphaBlend(
colors.primary.withValues(alpha: 0.16),
colors.surfaceContainerHigh,
),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
LucideIcons.calendarPlus,
size: 36,
color: colors.primary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.progressExamTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(
l10n.progressExamHint,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
FilledButton.tonalIcon(
icon: const Icon(LucideIcons.graduationCap),
label: Text(l10n.progressExamChoose),
onPressed: () => _chooseExam(context),
),
],
),
),
),
);
}
}
/// How far something has come: a bar, then [label]. /// How far something has come: a bar, then [label].
class const _Bar({ class const _Bar({
required final int done, required final int done,

View file

@ -783,6 +783,13 @@ class const ProgressPart({
final toReview = _Tile( final toReview = _Tile(
title: l10n.progressToReview, title: l10n.progressToReview,
info: l10n.progressToReviewInfo, info: l10n.progressToReviewInfo,
trailing: Icon(
LucideIcons.history,
size: 20,
color: progress.reviewsDue > 0
? theme.extras.warning
: colors.onSurfaceVariant,
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -840,8 +847,8 @@ class const ProgressPart({
const SizedBox(height: 12), const SizedBox(height: 12),
LinearProgressIndicator( LinearProgressIndicator(
value: learned, value: learned,
minHeight: 8, minHeight: 10,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
// A plain track: the theme's tinted one reads as full // A plain track: the theme's tinted one reads as full
// at 0 % in dark mode. // at 0 % in dark mode.
backgroundColor: colors.surfaceContainerHighest, backgroundColor: colors.surfaceContainerHighest,
@ -891,6 +898,9 @@ class const ProgressPart({
], ],
), ),
), ),
// The exam, as Vaia asks for its date below the figures: how
// ready the person is for one chosen, else an invitation.
ProgressExam(studysetId: studysetId),
if (material != null && material.steps > 0) ...[ if (material != null && material.steps > 0) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_PathsTile(progress: material), _PathsTile(progress: material),
@ -1091,36 +1101,45 @@ class const _Tile({
required final String title, required final String title,
required final String info, required final String info,
required final Widget child, required final Widget child,
final Widget? trailing,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
// Filled, without an outline, as Vaia's (decided 2026-11-02).
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainer, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Flexible( // The title takes what the icon at the end leaves.
child: Text(title, style: theme.textTheme.titleMedium), Expanded(
), child: Row(
const SizedBox(width: 4), children: [
Tooltip( Flexible(
message: info, child: Text(title, style: theme.textTheme.titleMedium),
triggerMode: TooltipTriggerMode.tap, ),
child: Icon( const SizedBox(width: 6),
LucideIcons.info, Tooltip(
size: 16, message: info,
color: theme.colorScheme.onSurfaceVariant, triggerMode: TooltipTriggerMode.tap,
child: Icon(
LucideIcons.info,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
), ),
), ),
?trailing,
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View file

@ -220,7 +220,12 @@ void main() {
(s) => s.goal != null && s.layout.hidden.isNotEmpty, (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); 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/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/data/review_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/rating.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:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.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_card_gateway.dart';
import '../../../support/fake_review_gateway.dart'; import '../../../support/fake_review_gateway.dart';
import '../../../support/fake_settings_gateway.dart'; import '../../../support/fake_settings_gateway.dart';
import '../../../support/fake_studyset_gateway.dart';
import '../../../support/pump_app.dart'; import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart'; import '../../../support/test_dependencies.dart';
@ -22,8 +27,10 @@ void main() {
late FakeCardGateway cardGateway; late FakeCardGateway cardGateway;
late FakeReviewGateway reviewGateway; late FakeReviewGateway reviewGateway;
late FakeSettingsGateway settingsGateway; late FakeSettingsGateway settingsGateway;
late FakeStudysetGateway studysetGateway;
setUp(() { setUp(() {
studysetGateway = FakeStudysetGateway();
cardGateway = FakeCardGateway(); cardGateway = FakeCardGateway();
reviewGateway = FakeReviewGateway(); reviewGateway = FakeReviewGateway();
settingsGateway = FakeSettingsGateway(); settingsGateway = FakeSettingsGateway();
@ -36,6 +43,7 @@ void main() {
await pumpApp( await pumpApp(
tester, tester,
testDependencies( testDependencies(
studysetGateway: studysetGateway,
cardGateway: cardGateway, cardGateway: cardGateway,
reviewGateway: reviewGateway, reviewGateway: reviewGateway,
settingsGateway: settingsGateway, settingsGateway: settingsGateway,
@ -46,22 +54,113 @@ void main() {
int streak(WidgetTester tester) => int streak(WidgetTester tester) =>
tester.widget<StreakChip>(find.byType(StreakChip)).streak; tester.widget<StreakChip>(find.byType(StreakChip)).streak;
/// Rates a new card now. /// Rates a new card in [studyset] at [at], now by default.
Future<void> rateToday() async { Future<void> rateToday({String studyset = 'japanese', DateTime? at}) async {
final cards = CardRepository(cardGateway); final cards = CardRepository(cardGateway);
addTearDown(cards.dispose); addTearDown(cards.dispose);
final id = await cards.create( final id = await cards.create(
'japanese', studyset,
CardContent(front: RichContent.empty(), back: RichContent.empty()), CardContent(front: RichContent.empty(), back: RichContent.empty()),
); );
await ReviewRepository(reviewGateway).record( await ReviewRepository(reviewGateway).record(
cardId: id, cardId: id,
rating: Rating.good, rating: Rating.good,
reviewedAt: DateTime.now(), reviewedAt: at ?? DateTime.now(),
duration: Duration.zero, 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', ( testWidgets('cobalt washes down from the top, behind a clear app bar', (
tester, tester,
) async { ) async {
@ -246,6 +345,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
for (final name in [ for (final name in [
'Daily goal', 'Daily goal',
'Pick up learning',
'Pomodoro', 'Pomodoro',
'PDF reading deadlines', 'PDF reading deadlines',
'Week overview', 'Week overview',

View file

@ -160,6 +160,7 @@ void main() {
order: [ order: [
HomeWidget.activity, HomeWidget.activity,
HomeWidget.goal, HomeWidget.goal,
HomeWidget.pickUp,
HomeWidget.pomodoro, HomeWidget.pomodoro,
HomeWidget.deadlines, HomeWidget.deadlines,
HomeWidget.week, HomeWidget.week,
@ -172,7 +173,8 @@ void main() {
}); });
test('a home layout from another version keeps the widgets this one ' 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( final settings = decodeSettingsPayload(
encodePayload(3, { encodePayload(3, {
'studyTags': {}, 'studyTags': {},
@ -184,15 +186,39 @@ void main() {
); );
expect(settings.home.order, [ expect(settings.home.order, [
HomeWidget.activity,
HomeWidget.goal, HomeWidget.goal,
HomeWidget.pickUp,
HomeWidget.pomodoro, HomeWidget.pomodoro,
HomeWidget.deadlines, HomeWidget.deadlines,
HomeWidget.week, HomeWidget.week,
HomeWidget.activity,
]); ]);
expect(settings.home.hidden, isEmpty); 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', () { test('folding keeps both rest days, and the goal one of them has', () {
final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)}); final mine = PersonSettings(restDays: {DateTime(2026, 10, 5)});
final theirs = PersonSettings( final theirs = PersonSettings(

View file

@ -2051,6 +2051,35 @@ void main() {
expect(find.text('1 min'), findsOneWidget); 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, ' testWidgets('Progress also counts the steps of the learning paths done, '
'and lists the PDFs not read to their end, begun first, across the ' 'and lists the PDFs not read to their end, begun first, across the '
'topics; one opens where it is read', (tester) async { 'topics; one opens where it is read', (tester) async {