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:
parent
447e00c52a
commit
a0bb75f1ad
12 changed files with 976 additions and 40 deletions
|
|
@ -1380,6 +1380,16 @@
|
|||
"readerCaptureFailed": "Die Seite konnte nicht dargestellt werden. Versuche es erneut, sobald sie geladen ist.",
|
||||
"readerScreenshotSaved": "Bildschirmfoto gespeichert",
|
||||
"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",
|
||||
"deadlinesTitle": "Lesefristen",
|
||||
"deadlinesCurrent": "Aktuell",
|
||||
|
|
@ -1953,6 +1963,9 @@
|
|||
"progressToReview": "Zu wiederholen",
|
||||
"progressToReviewInfo": "Schon gelernte Karten, die heute wieder fällig sind.",
|
||||
"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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1577,6 +1577,22 @@
|
|||
"readerCaptureFailed": "The page couldn't be drawn. Try again once it has loaded.",
|
||||
"readerScreenshotSaved": "Screenshot saved",
|
||||
"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",
|
||||
"deadlinesTitle": "Reading deadlines",
|
||||
"deadlinesCurrent": "Current",
|
||||
|
|
@ -2329,6 +2345,9 @@
|
|||
"progressToReviewInfo": "Cards studied before that are due again today.",
|
||||
"progressDueToday": "{count, plural, =1{card due today} other{cards due today}}",
|
||||
"@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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -8140,6 +8140,66 @@ abstract class AppLocalizations {
|
|||
/// **'Open the PDF at this page'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -11534,6 +11594,24 @@ abstract class AppLocalizations {
|
|||
/// **'{count, plural, =1{card due today} other{cards due today}}'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -5006,6 +5006,68 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get homeWidgetDeadlines => 'PDF-Lesefristen';
|
||||
|
||||
|
|
@ -7301,6 +7363,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
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
|
||||
String get progressStudyTime => 'Lernzeit';
|
||||
|
||||
|
|
|
|||
|
|
@ -4959,6 +4959,68 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get homeWidgetDeadlines => 'PDF reading deadlines';
|
||||
|
||||
|
|
@ -7233,6 +7295,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
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
|
||||
String get progressStudyTime => 'Study time';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:equatable/equatable.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/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_goal.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';
|
||||
|
||||
/// 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
|
||||
/// past year, and how the person arranged it. It follows every rating, card,
|
||||
/// studysets to pick up learning in, the week with its rest days and the
|
||||
/// 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.
|
||||
class HomeBloc(
|
||||
final StudysetRepository _studysets,
|
||||
|
|
@ -144,6 +146,9 @@ class HomeBloc(
|
|||
if (!resting.contains(card.studysetId)) card.id,
|
||||
];
|
||||
final schedules = _reviews.schedulesFrom(reviews, active);
|
||||
final pickUp = pickUpOf(notDeleted(everything), {
|
||||
for (final card in cards) card.id: card.studysetId,
|
||||
}, reviews);
|
||||
if (refresh != _refreshes) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -157,6 +162,7 @@ class HomeBloc(
|
|||
due: dueByDay([
|
||||
for (final schedule in schedules.values) ?schedule.due,
|
||||
], localDay(now)),
|
||||
pickUp: pickUp,
|
||||
goal: settings.goal,
|
||||
goalProgress: switch (settings.goal) {
|
||||
final goal? => goalProgress(goal, ratings, now),
|
||||
|
|
@ -245,6 +251,9 @@ final class const HomeStateLoaded({
|
|||
/// How many cards fall due on each day.
|
||||
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.
|
||||
required final StudyGoal? goal,
|
||||
|
||||
|
|
@ -261,6 +270,7 @@ final class const HomeStateLoaded({
|
|||
restDays,
|
||||
streak,
|
||||
due,
|
||||
pickUp,
|
||||
goal,
|
||||
goalProgress,
|
||||
layout,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ enum HomeWidget() {
|
|||
/// The study goal.
|
||||
goal,
|
||||
|
||||
/// The studysets studied, the latest first, to pick up learning in.
|
||||
pickUp,
|
||||
|
||||
/// The Pomodoro timer.
|
||||
pomodoro,
|
||||
|
||||
|
|
@ -29,16 +32,23 @@ final class const HomeLayout({
|
|||
final Set<HomeWidget> hidden = const {},
|
||||
}) with Equatable {
|
||||
/// The layout with [order] (widgets a newer version knows dropped,
|
||||
/// widgets it misses added at the end) and [hidden].
|
||||
factory reconciled(List<HomeWidget> order, Set<HomeWidget> hidden) =>
|
||||
HomeLayout(
|
||||
order: [
|
||||
...{...order},
|
||||
for (final widget in HomeWidget.values)
|
||||
if (!order.contains(widget)) widget,
|
||||
],
|
||||
hidden: hidden,
|
||||
);
|
||||
/// widgets it misses added each after the one before it in a new
|
||||
/// dashboard, so that a widget new in this version shows where a new
|
||||
/// dashboard has it, not below everything) and [hidden].
|
||||
factory reconciled(List<HomeWidget> order, Set<HomeWidget> hidden) {
|
||||
final reconciled = [
|
||||
...{...order},
|
||||
];
|
||||
for (final (index, widget) in HomeWidget.values.indexed) {
|
||||
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.
|
||||
List<HomeWidget> get shown => [
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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/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/pick_up_card.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/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';
|
||||
|
||||
/// 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
|
||||
/// ([TopGradient]). Where the person searches the web or asks an assistant
|
||||
/// from the app, its button stands at the lower left ([AssistantButton]).
|
||||
|
|
@ -199,6 +201,11 @@ class _HomeState() extends State<_Home> {
|
|||
progress: state.goalProgress,
|
||||
onChanged: (goal) => _send(HomeEventGoalSet(goal)),
|
||||
),
|
||||
HomeWidget.pickUp => PickUpCard(
|
||||
pickUp: state.pickUp,
|
||||
now: state.now,
|
||||
placeholder: _arranging,
|
||||
),
|
||||
HomeWidget.deadlines => ReadingDeadlinesCard(placeholder: _arranging),
|
||||
HomeWidget.week => Card(
|
||||
child: Padding(
|
||||
|
|
@ -255,9 +262,11 @@ class _HomeState() extends State<_Home> {
|
|||
// Taps still reach the widget's own controls.
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onLongPress: _arrange,
|
||||
// The reading deadlines leave no gap while they show
|
||||
// nothing: they keep their own.
|
||||
child: widget == HomeWidget.deadlines
|
||||
// The reading deadlines and Pick up learning leave no
|
||||
// gap while they show nothing: they keep their own.
|
||||
child:
|
||||
widget == HomeWidget.deadlines ||
|
||||
widget == HomeWidget.pickUp
|
||||
? section(widget)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
|
|
@ -328,6 +337,7 @@ class const _Arranging({
|
|||
String label(HomeWidget widget) => switch (widget) {
|
||||
HomeWidget.pomodoro => l10n.pomodoroTitle,
|
||||
HomeWidget.goal => l10n.homeWidgetGoal,
|
||||
HomeWidget.pickUp => l10n.homeWidgetPickUp,
|
||||
HomeWidget.deadlines => l10n.homeWidgetDeadlines,
|
||||
HomeWidget.week => l10n.homeWidgetWeek,
|
||||
HomeWidget.activity => l10n.homeWidgetActivity,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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].
|
||||
class const _Bar({
|
||||
required final int done,
|
||||
|
|
|
|||
|
|
@ -783,6 +783,13 @@ class const ProgressPart({
|
|||
final toReview = _Tile(
|
||||
title: l10n.progressToReview,
|
||||
info: l10n.progressToReviewInfo,
|
||||
trailing: Icon(
|
||||
LucideIcons.history,
|
||||
size: 20,
|
||||
color: progress.reviewsDue > 0
|
||||
? theme.extras.warning
|
||||
: colors.onSurfaceVariant,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -840,8 +847,8 @@ class const ProgressPart({
|
|||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(
|
||||
value: learned,
|
||||
minHeight: 8,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
minHeight: 10,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
// A plain track: the theme's tinted one reads as full
|
||||
// at 0 % in dark mode.
|
||||
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) ...[
|
||||
const SizedBox(height: 12),
|
||||
_PathsTile(progress: material),
|
||||
|
|
@ -1091,36 +1101,45 @@ class const _Tile({
|
|||
required final String title,
|
||||
required final String info,
|
||||
required final Widget child,
|
||||
final Widget? trailing,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
// Filled, without an outline, as Vaia's (decided 2026-11-02).
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Tooltip(
|
||||
message: info,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
// The title takes what the icon at the end leaves.
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(title, style: theme.textTheme.titleMedium),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: info,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
|
|
|||
Loading…
Reference in a new issue