feat(learn): the library on shelves by recency, narrowed to a subject (ADR 0041, D8)
The library as Vaia's (the developer's decisions of 10 Nov 2026): the starred on a shelf of their own, then those studied or changed in the last 7 days, the last month and before, each under its heading, the archive last. Recent is the later of a card's last rating (StudyProgress.lastStudied) and the studyset's own last change. A dropdown narrows the library to one of the subjects in use, kept on the device (DeviceSettings.librarySubject). A card names its subject under its title, with its icon, and a language course its language beside. Whats-New: The library groups your studysets by when you last used them, and narrows them to one subject. Whats-New-De: Die Bibliothek ordnet deine Lernsets danach, wann du sie zuletzt genutzt hast, und zeigt auf Wunsch nur ein Fach. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9868dc27c6
commit
a3b124ddcb
18 changed files with 786 additions and 26 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2165,5 +2165,11 @@
|
|||
"subjectOther": "Sonstiges / unsicher",
|
||||
"studysetSheetSubject": "Fach",
|
||||
"studysetSheetSubjectNone": "Fach wählen",
|
||||
"subjectPickerTitle": "Fach"
|
||||
"subjectPickerTitle": "Fach",
|
||||
"libraryAllSubjects": "Alle Fächer",
|
||||
"librarySubjectTooltip": "Ein Fach zeigen",
|
||||
"libraryShelfStarred": "Mit Stern",
|
||||
"libraryShelfLastWeek": "Letzte 7 Tage",
|
||||
"libraryShelfLastMonth": "Letzter Monat",
|
||||
"libraryShelfOlder": "Älter"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2656,5 +2656,17 @@
|
|||
"studysetSheetSubjectNone": "Choose a subject",
|
||||
"@studysetSheetSubjectNone": {"description": "The subject field before one is chosen."},
|
||||
"subjectPickerTitle": "Subject",
|
||||
"@subjectPickerTitle": {"description": "The title of the sheet picking a studyset's subject."}
|
||||
"@subjectPickerTitle": {"description": "The title of the sheet picking a studyset's subject."},
|
||||
"libraryAllSubjects": "All subjects",
|
||||
"@libraryAllSubjects": {"description": "The library's subject dropdown while it shows every subject."},
|
||||
"librarySubjectTooltip": "Show one subject",
|
||||
"@librarySubjectTooltip": {"description": "The tooltip of the library's subject dropdown."},
|
||||
"libraryShelfStarred": "Starred",
|
||||
"@libraryShelfStarred": {"description": "The library's heading over the starred studysets."},
|
||||
"libraryShelfLastWeek": "Last 7 days",
|
||||
"@libraryShelfLastWeek": {"description": "The library's heading over the studysets studied or changed in the last 7 days."},
|
||||
"libraryShelfLastMonth": "Last month",
|
||||
"@libraryShelfLastMonth": {"description": "The library's heading over the studysets studied or changed in the last 30 days, before the last 7."},
|
||||
"libraryShelfOlder": "Older",
|
||||
"@libraryShelfOlder": {"description": "The library's heading over the studysets studied or changed longer ago."}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12775,6 +12775,42 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Subject'**
|
||||
String get subjectPickerTitle;
|
||||
|
||||
/// The library's subject dropdown while it shows every subject.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'All subjects'**
|
||||
String get libraryAllSubjects;
|
||||
|
||||
/// The tooltip of the library's subject dropdown.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show one subject'**
|
||||
String get librarySubjectTooltip;
|
||||
|
||||
/// The library's heading over the starred studysets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Starred'**
|
||||
String get libraryShelfStarred;
|
||||
|
||||
/// The library's heading over the studysets studied or changed in the last 7 days.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last 7 days'**
|
||||
String get libraryShelfLastWeek;
|
||||
|
||||
/// The library's heading over the studysets studied or changed in the last 30 days, before the last 7.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Last month'**
|
||||
String get libraryShelfLastMonth;
|
||||
|
||||
/// The library's heading over the studysets studied or changed longer ago.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Older'**
|
||||
String get libraryShelfOlder;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -8170,4 +8170,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get subjectPickerTitle => 'Fach';
|
||||
|
||||
@override
|
||||
String get libraryAllSubjects => 'Alle Fächer';
|
||||
|
||||
@override
|
||||
String get librarySubjectTooltip => 'Ein Fach zeigen';
|
||||
|
||||
@override
|
||||
String get libraryShelfStarred => 'Mit Stern';
|
||||
|
||||
@override
|
||||
String get libraryShelfLastWeek => 'Letzte 7 Tage';
|
||||
|
||||
@override
|
||||
String get libraryShelfLastMonth => 'Letzter Monat';
|
||||
|
||||
@override
|
||||
String get libraryShelfOlder => 'Älter';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8097,6 +8097,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get subjectPickerTitle => 'Subject';
|
||||
|
||||
@override
|
||||
String get libraryAllSubjects => 'All subjects';
|
||||
|
||||
@override
|
||||
String get librarySubjectTooltip => 'Show one subject';
|
||||
|
||||
@override
|
||||
String get libraryShelfStarred => 'Starred';
|
||||
|
||||
@override
|
||||
String get libraryShelfLastWeek => 'Last 7 days';
|
||||
|
||||
@override
|
||||
String get libraryShelfLastMonth => 'Last month';
|
||||
|
||||
@override
|
||||
String get libraryShelfOlder => 'Older';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ class DeviceSettingsBloc(
|
|||
on<DeviceSettingsEventCheckGrammarChosen>(
|
||||
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
|
||||
);
|
||||
on<DeviceSettingsEventLibrarySubjectChosen>(
|
||||
(event, emit) => _apply(emit, state.withLibrarySubject(event.subject)),
|
||||
);
|
||||
on<DeviceSettingsEventSidebarChosen>(
|
||||
(event, emit) => _apply(emit, state.withSidebar(open: event.open)),
|
||||
);
|
||||
|
|
@ -151,6 +154,14 @@ final class const DeviceSettingsEventCheckGrammarChosen(final bool on)
|
|||
List<Object?> get props => [on];
|
||||
}
|
||||
|
||||
/// The person narrows the library to the subject [subject] (its ID), or
|
||||
/// shows all subjects again (null).
|
||||
final class const DeviceSettingsEventLibrarySubjectChosen(final String? subject)
|
||||
extends DeviceSettingsEvent {
|
||||
@override
|
||||
List<Object?> get props => [subject];
|
||||
}
|
||||
|
||||
/// The person opens the rail into the sidebar, or closes it ([open]).
|
||||
final class const DeviceSettingsEventSidebarChosen(final bool open)
|
||||
extends DeviceSettingsEvent {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
|||
},
|
||||
checkGrammar: json['checkGrammar'] != false,
|
||||
sidebarOpen: json['sidebarOpen'] == true,
|
||||
librarySubject: switch (json['librarySubject']) {
|
||||
final String id => id,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
} on FormatException catch (error) {
|
||||
log('The device settings are unreadable', name: 'Settings', error: error);
|
||||
|
|
@ -107,6 +111,7 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
|
|||
},
|
||||
'checkGrammar': settings.checkGrammar,
|
||||
if (settings.sidebarOpen) 'sidebarOpen': true,
|
||||
'librarySubject': ?settings.librarySubject,
|
||||
}),
|
||||
flush: true,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@ final class const DeviceSettings({
|
|||
/// (decided 10 Nov 2026), halkyon_app's `Look.sidebarOpen` kept here.
|
||||
final bool sidebarOpen = false,
|
||||
|
||||
/// The ID of the subject the library is narrowed to (ADR 0041, D8,
|
||||
/// decided 10 Nov 2026); null for all subjects.
|
||||
final String? librarySubject,
|
||||
|
||||
/// Whether the editors underline what the instance's LanguageTool finds
|
||||
/// as the person writes (ADR 0024, Settings › Writing): on unless turned
|
||||
/// off; nothing is checked where the instance checks nothing.
|
||||
|
|
@ -119,6 +123,10 @@ final class const DeviceSettings({
|
|||
/// These settings with the sidebar [open] or closed.
|
||||
DeviceSettings withSidebar({required bool open}) => _copy(sidebarOpen: open);
|
||||
|
||||
/// These settings with the library narrowed to the subject [id]; null
|
||||
/// for all subjects.
|
||||
DeviceSettings withLibrarySubject(String? id) => _copy(librarySubject: id);
|
||||
|
||||
/// These settings with writing checked as it is written [on] or off.
|
||||
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
|
||||
|
||||
|
|
@ -136,6 +144,7 @@ final class const DeviceSettings({
|
|||
Map<String, LlmEffort>? askEfforts,
|
||||
bool? checkGrammar,
|
||||
bool? sidebarOpen,
|
||||
Object? librarySubject = _keep,
|
||||
}) => DeviceSettings(
|
||||
appearance: appearance ?? this.appearance,
|
||||
language: identical(language, _keep) ? this.language : language as String?,
|
||||
|
|
@ -154,6 +163,9 @@ final class const DeviceSettings({
|
|||
askEfforts: askEfforts ?? this.askEfforts,
|
||||
checkGrammar: checkGrammar ?? this.checkGrammar,
|
||||
sidebarOpen: sidebarOpen ?? this.sidebarOpen,
|
||||
librarySubject: identical(librarySubject, _keep)
|
||||
? this.librarySubject
|
||||
: librarySubject as String?,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -171,6 +183,7 @@ final class const DeviceSettings({
|
|||
askEfforts,
|
||||
checkGrammar,
|
||||
sidebarOpen,
|
||||
librarySubject,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ final class const StudyProgress({
|
|||
|
||||
/// How many files (PDFs) there are with the cards, counted as notes are.
|
||||
final int files = 0,
|
||||
|
||||
/// When a card among them was last rated, in UTC; null when none was.
|
||||
/// The library shelves by it (ADR 0041, D8).
|
||||
final DateTime? lastStudied,
|
||||
}) with Equatable {
|
||||
/// The share of mature cards, from 0 to 1; null without cards.
|
||||
double? get mastery => cards == 0 ? null : mature / cards;
|
||||
|
|
@ -47,6 +51,7 @@ final class const StudyProgress({
|
|||
notes: notes,
|
||||
paths: paths,
|
||||
files: files,
|
||||
lastStudied: lastStudied,
|
||||
);
|
||||
|
||||
@override
|
||||
|
|
@ -58,6 +63,7 @@ final class const StudyProgress({
|
|||
notes,
|
||||
paths,
|
||||
files,
|
||||
lastStudied,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -88,5 +94,12 @@ StudyProgress studyProgress({
|
|||
due: queue.length,
|
||||
reviewsDue: queue.where((id) => !(schedules[id]?.isNew ?? true)).length,
|
||||
mature: cards.where((id) => schedules[id]?.isMature ?? false).length,
|
||||
lastStudied: cards.fold<DateTime?>(
|
||||
null,
|
||||
(last, id) => switch (schedules[id]?.lastReviewedAt) {
|
||||
final at? when last == null || at.isAfter(last) => at,
|
||||
_ => last,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
|
||||
/// The shelves of the library (ADR 0041, D8, decided by the developer, 10
|
||||
/// Nov 2026, as Vaia's), in the order they show.
|
||||
enum LibraryShelf() {
|
||||
/// The starred studysets, however long ago they were used.
|
||||
starred,
|
||||
|
||||
/// Studied or changed in the last 7 days.
|
||||
lastWeek,
|
||||
|
||||
/// Within the last 30 days, before those.
|
||||
lastMonth,
|
||||
|
||||
/// Before.
|
||||
older,
|
||||
}
|
||||
|
||||
/// When [studyset] was last used: the later of when a card in it was last
|
||||
/// rated ([StudyProgress.lastStudied]) and when it last changed itself;
|
||||
/// for one from before changes were recorded, when it was made, as its
|
||||
/// UUID v7 says. Null where nothing says.
|
||||
DateTime? lastUsedOf(Studyset studyset, StudyProgress? progress) {
|
||||
DateTime? latest;
|
||||
for (final at in [
|
||||
progress?.lastStudied,
|
||||
studyset.updatedAt ?? uuidV7Time(studyset.id),
|
||||
]) {
|
||||
if (at != null && (latest == null || at.isAfter(latest))) {
|
||||
latest = at;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
/// [studysets] on their shelves at [now], each shelf's most recently used
|
||||
/// first; a shelf without any is left out.
|
||||
Map<LibraryShelf, List<Studyset>> shelvesOf(
|
||||
List<Studyset> studysets, {
|
||||
required Map<String, StudyProgress> progress,
|
||||
required DateTime now,
|
||||
}) {
|
||||
final used = {
|
||||
for (final studyset in studysets)
|
||||
studyset.id: lastUsedOf(studyset, progress[studyset.id]),
|
||||
};
|
||||
LibraryShelf shelfOf(Studyset studyset) {
|
||||
if (studyset.isStarred) {
|
||||
return LibraryShelf.starred;
|
||||
}
|
||||
final age = switch (used[studyset.id]) {
|
||||
final at? => now.difference(at),
|
||||
null => null,
|
||||
};
|
||||
return switch (age) {
|
||||
final age? when age < const Duration(days: 7) => LibraryShelf.lastWeek,
|
||||
final age? when age < const Duration(days: 30) => LibraryShelf.lastMonth,
|
||||
_ => LibraryShelf.older,
|
||||
};
|
||||
}
|
||||
|
||||
final sorted = [...studysets]
|
||||
..sort(
|
||||
(a, b) => switch ((used[a.id], used[b.id])) {
|
||||
(final a?, final b?) when a != b => b.compareTo(a),
|
||||
(_?, null) => -1,
|
||||
(null, _?) => 1,
|
||||
_ => b.id.compareTo(a.id),
|
||||
},
|
||||
);
|
||||
final shelves = <LibraryShelf, List<Studyset>>{};
|
||||
for (final studyset in sorted) {
|
||||
(shelves[shelfOf(studyset)] ??= []).add(studyset);
|
||||
}
|
||||
return {
|
||||
for (final shelf in LibraryShelf.values) shelf: ?shelves[shelf],
|
||||
};
|
||||
}
|
||||
|
||||
/// The subject [studyset] counts under in the library: its own, or
|
||||
/// [Subject.other] for none or one this app doesn't know.
|
||||
Subject librarySubjectOf(Studyset studyset) =>
|
||||
Subject.byId(studyset.content.subject) ?? Subject.other;
|
||||
|
||||
/// Whether [studyset] is listed when the library is narrowed to [subject].
|
||||
bool filedUnder(Studyset studyset, Subject subject) =>
|
||||
librarySubjectOf(studyset) == subject;
|
||||
|
||||
/// The subjects [studysets] are filed under, each once, in [Subject]'s
|
||||
/// order: what the library's dropdown offers.
|
||||
List<Subject> subjectsInUse(List<Studyset> studysets) {
|
||||
final used = {for (final studyset in studysets) librarySubjectOf(studyset)};
|
||||
return [
|
||||
for (final subject in Subject.values)
|
||||
if (used.contains(subject)) subject,
|
||||
];
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/bloc/device_settings_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/archive/widget/import_archive.dart';
|
||||
|
|
@ -16,8 +18,10 @@ import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.da
|
|||
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_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/library_shelves.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/delete_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/deleted_studyset_card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||
|
|
@ -25,13 +29,16 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.
|
|||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_navigation.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/subject_picker.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The library tab: the person's studysets as cards in a grid, starred ones
|
||||
/// first, and archived ones in a section of their own below, closed until
|
||||
/// opened. A search above the grid finds studysets by title and secondary
|
||||
/// The library tab: the person's studysets as cards in a grid, on shelves
|
||||
/// as Vaia's (ADR 0041, D8): the starred, then those studied or changed in
|
||||
/// the last 7 days, the last month and before, narrowed to one subject
|
||||
/// where the person chose one; archived ones in a section of their own
|
||||
/// below, closed until opened. A search above the grid finds studysets by title and secondary
|
||||
/// title, archived ones too.
|
||||
///
|
||||
/// Creating a studyset is a floating button in compact windows, which slides
|
||||
|
|
@ -494,7 +501,7 @@ class _GridState() extends State<_Grid> {
|
|||
// a row lines up.
|
||||
static const _layout = SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 400,
|
||||
mainAxisExtent: 188,
|
||||
mainAxisExtent: 212,
|
||||
crossAxisSpacing: _spacing,
|
||||
mainAxisSpacing: _spacing,
|
||||
);
|
||||
|
|
@ -512,19 +519,34 @@ class _GridState() extends State<_Grid> {
|
|||
_published = context.watch<PublishedCubit>().state;
|
||||
final query = widget.query.trim().toLowerCase();
|
||||
final searching = query.isNotEmpty;
|
||||
// Narrowed to the subject the device keeps, while one is in use (ADR
|
||||
// 0041, D8); all of them otherwise.
|
||||
final inUse = subjectsInUse(widget.studysets);
|
||||
final kept = Subject.byId(
|
||||
context.select<DeviceSettingsBloc, String?>(
|
||||
(settings) => settings.state.librarySubject,
|
||||
),
|
||||
);
|
||||
final subject = inUse.contains(kept) ? kept : null;
|
||||
bool listed(Studyset studyset) =>
|
||||
(subject == null || filedUnder(studyset, subject)) &&
|
||||
(!searching || _matches(studyset, query));
|
||||
final active = [
|
||||
for (final studyset in widget.studysets)
|
||||
if (!studyset.isArchived && (!searching || _matches(studyset, query)))
|
||||
studyset,
|
||||
if (!studyset.isArchived && listed(studyset)) studyset,
|
||||
];
|
||||
final shelves = shelvesOf(
|
||||
active,
|
||||
progress: widget.progress,
|
||||
now: clock.now().toUtc(),
|
||||
);
|
||||
final archived = [
|
||||
for (final studyset in widget.studysets)
|
||||
if (studyset.isArchived && (!searching || _matches(studyset, query)))
|
||||
studyset,
|
||||
if (studyset.isArchived && listed(studyset)) studyset,
|
||||
];
|
||||
final deleted = [
|
||||
for (final studyset in widget.deleted)
|
||||
if (!searching || _matches(studyset, query)) studyset,
|
||||
if (listed(studyset)) studyset,
|
||||
];
|
||||
// A search opens the sections, so their matches show; closing the
|
||||
// search brings them back as the person left them.
|
||||
|
|
@ -549,14 +571,26 @@ class _GridState() extends State<_Grid> {
|
|||
SliverToBoxAdapter(
|
||||
child: _PrivateOffer(studysets: widget.studysets),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(_spacing),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: _layout,
|
||||
itemCount: active.length,
|
||||
itemBuilder: (context, index) => _card(context, active[index]),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _SubjectDropdown(subjects: inUse, selected: subject),
|
||||
),
|
||||
for (final MapEntry(key: shelf, value: listed)
|
||||
in shelves.entries) ...[
|
||||
SliverToBoxAdapter(child: _ShelfHeading(shelf)),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_spacing,
|
||||
0,
|
||||
_spacing,
|
||||
_spacing,
|
||||
),
|
||||
sliver: SliverGrid.builder(
|
||||
gridDelegate: _layout,
|
||||
itemCount: listed.length,
|
||||
itemBuilder: (context, index) => _card(context, listed[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (archived.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _SectionHeader(
|
||||
|
|
@ -882,6 +916,93 @@ class const _DeletedNote() extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The subject the library is narrowed to, chosen among those in use (ADR
|
||||
/// 0041, D8): all at first, the choice kept on the device.
|
||||
class const _SubjectDropdown({
|
||||
required final List<Subject> subjects,
|
||||
required final Subject? selected,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
void choose(Subject? subject) => context.read<DeviceSettingsBloc>().add(
|
||||
DeviceSettingsEventLibrarySubjectChosen(subject?.id),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(_GridState._spacing, 4, 16, 0),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: MenuAnchor(
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(LucideIcons.layoutGrid, size: 20),
|
||||
trailingIcon: selected == null
|
||||
? const Icon(LucideIcons.check, size: 18)
|
||||
: null,
|
||||
onPressed: () => choose(null),
|
||||
child: Text(l10n.libraryAllSubjects),
|
||||
),
|
||||
for (final subject in subjects)
|
||||
MenuItemButton(
|
||||
leadingIcon: SubjectBadge(subject, size: 24),
|
||||
trailingIcon: subject == selected
|
||||
? const Icon(LucideIcons.check, size: 18)
|
||||
: null,
|
||||
onPressed: () => choose(subject),
|
||||
child: Text(subject.nameIn(l10n)),
|
||||
),
|
||||
],
|
||||
builder: (context, menu, _) => Tooltip(
|
||||
message: l10n.librarySubjectTooltip,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => menu.isOpen ? menu.close() : menu.open(),
|
||||
icon: switch (selected) {
|
||||
final subject? => SubjectBadge(subject, size: 22),
|
||||
null => const Icon(LucideIcons.layoutGrid),
|
||||
},
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
selected?.nameIn(l10n) ?? l10n.libraryAllSubjects,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(LucideIcons.chevronDown, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading over a shelf of the library (ADR 0041, D8).
|
||||
class const _ShelfHeading(final LibraryShelf shelf) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Text(
|
||||
switch (shelf) {
|
||||
LibraryShelf.starred => l10n.libraryShelfStarred,
|
||||
LibraryShelf.lastWeek => l10n.libraryShelfLastWeek,
|
||||
LibraryShelf.lastMonth => l10n.libraryShelfLastMonth,
|
||||
LibraryShelf.older => l10n.libraryShelfOlder,
|
||||
},
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section's header (the archive, Recently deleted): it opens and closes
|
||||
/// the section, and counts what rests there.
|
||||
class const _SectionHeader({
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/l10n/content_languages.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/theme/learn_colors.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/publish/widget/share_button.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// A studyset in the library. On top a bar in its colour; below, its title
|
||||
/// with the secondary title as a kicker, and what it holds; beside the title
|
||||
/// with the secondary title as a kicker, the subject it is filed under
|
||||
/// (ADR 0041, D8), and what it holds; beside the title
|
||||
/// the action it invites, inside a ring its mature cards fill, with how many
|
||||
/// cards are due; at the bottom who made it, its star, whether it is
|
||||
/// public and its options.
|
||||
|
|
@ -124,6 +127,14 @@ class _StudysetCardState() extends State<StudysetCard> {
|
|||
onAdd: widget.onOpen,
|
||||
),
|
||||
),
|
||||
if (Subject.byId(studyset.content.subject)
|
||||
case final subject?) ...[
|
||||
const SizedBox(height: 2),
|
||||
_SubjectLine(
|
||||
subject: subject,
|
||||
language: studyset.content.frontLanguage,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
_Holds(progress: widget.progress),
|
||||
],
|
||||
|
|
@ -164,6 +175,41 @@ String _by(AppLocalizations l10n, Studyset studyset) {
|
|||
return handle == null ? l10n.studysetByOthers : l10n.studysetByAuthor(handle);
|
||||
}
|
||||
|
||||
/// The subject a studyset is filed under, with its icon; a language course
|
||||
/// names its language beside (ADR 0041, D3).
|
||||
class const _SubjectLine({
|
||||
required final Subject subject,
|
||||
required final String? language,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final style = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final name = subject.nameIn(l10n);
|
||||
return Row(
|
||||
children: [
|
||||
Icon(subject.icon, size: 14, color: style?.color),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
switch (language) {
|
||||
final tag? when subject == Subject.languages =>
|
||||
'$name · ${contentLanguageName(l10n, tag)}',
|
||||
_ => name,
|
||||
},
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the studyset holds; a muted bar of the same height while that is
|
||||
/// still being worked out, which "No content yet" would misstate.
|
||||
class const _Holds({required final StudyProgress? progress})
|
||||
|
|
|
|||
|
|
@ -65,6 +65,18 @@ void main() {
|
|||
expect(read.withLanguage('de').sidebarOpen, isTrue);
|
||||
});
|
||||
|
||||
test('keeps the subject the library is narrowed to (ADR 0041, D8), all '
|
||||
'until one is chosen', () async {
|
||||
expect((await store.read()).librarySubject, isNull);
|
||||
|
||||
await store.write(const DeviceSettings(librarySubject: 'languages'));
|
||||
|
||||
final read = await FileDeviceSettingsStore(file).read();
|
||||
expect(read.librarySubject, 'languages');
|
||||
expect(read.withLibrarySubject(null).librarySubject, isNull);
|
||||
expect(read.withLanguage('de').librarySubject, 'languages');
|
||||
});
|
||||
|
||||
test('shows the Pomodoro timer while studying until told not to', () async {
|
||||
expect((await store.read()).pomodoroWhileStudying, isTrue);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,46 @@ void main() {
|
|||
expect(progress.mastery, 0.25);
|
||||
});
|
||||
|
||||
test('names when a card among them was last rated, for the library to '
|
||||
"shelve by (the developer's decision of 10 Nov 2026)", () {
|
||||
CardSchedule ratedAt(DateTime at) => CardSchedule(
|
||||
reviewCount: 1,
|
||||
due: now.add(const Duration(days: 2)),
|
||||
firstReviewedAt: at,
|
||||
lastReviewedAt: at,
|
||||
);
|
||||
final progress = studyProgress(
|
||||
groups: [
|
||||
['old', 'new'],
|
||||
['recent'],
|
||||
],
|
||||
schedules: {
|
||||
'old': ratedAt(DateTime.utc(2026, 9)),
|
||||
'new': fresh,
|
||||
'recent': ratedAt(DateTime.utc(2026, 10, 4)),
|
||||
},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
);
|
||||
|
||||
expect(progress.lastStudied, DateTime.utc(2026, 10, 4));
|
||||
expect(
|
||||
progress.withContent(notes: 1, paths: 0, files: 0).lastStudied,
|
||||
DateTime.utc(2026, 10, 4),
|
||||
);
|
||||
expect(
|
||||
studyProgress(
|
||||
groups: [
|
||||
['new'],
|
||||
],
|
||||
schedules: {'new': fresh},
|
||||
now: now,
|
||||
startOfToday: startOfToday,
|
||||
).lastStudied,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('counts only as many new cards as today still starts', () {
|
||||
final order = [for (var i = 0; i < 30; i++) 'n$i'];
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/library_shelves.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
|
||||
/// The library as Vaia's (ADR 0041, D8, the developer's decisions of 10 Nov
|
||||
/// 2026): the starred on top, then by when each was last studied or changed,
|
||||
/// in the last 7 days, the last month and before; narrowed to a subject.
|
||||
void main() {
|
||||
final now = DateTime.utc(2026, 11, 10, 12);
|
||||
|
||||
Studyset studyset(
|
||||
String id, {
|
||||
DateTime? updatedAt,
|
||||
bool starred = false,
|
||||
String? subject,
|
||||
}) => Studyset(
|
||||
id: id,
|
||||
content: StudysetContent(
|
||||
title: id,
|
||||
description: RichContent.empty(),
|
||||
subject: subject,
|
||||
),
|
||||
updatedAt: updatedAt,
|
||||
starredAt: starred ? DateTime.utc(2026) : null,
|
||||
);
|
||||
|
||||
StudyProgress studied(DateTime at) => StudyProgress(
|
||||
cards: 1,
|
||||
due: 0,
|
||||
reviewsDue: 0,
|
||||
mature: 0,
|
||||
lastStudied: at,
|
||||
);
|
||||
|
||||
test('shelves by the later of the last rating and the last change, the '
|
||||
'most recent first in each shelf', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
studyset('changed-today', updatedAt: now),
|
||||
studyset('studied-3-days-ago', updatedAt: DateTime.utc(2026, 1, 2)),
|
||||
studyset('changed-2-weeks-ago', updatedAt: DateTime.utc(2026, 10, 27)),
|
||||
studyset('old', updatedAt: DateTime.utc(2026, 1, 2)),
|
||||
studyset('yesterday', updatedAt: now.subtract(const Duration(days: 1))),
|
||||
],
|
||||
progress: {
|
||||
'studied-3-days-ago': studied(now.subtract(const Duration(days: 3))),
|
||||
'old': studied(DateTime.utc(2026, 2)),
|
||||
},
|
||||
now: now,
|
||||
);
|
||||
|
||||
List<String> ids(LibraryShelf shelf) => [
|
||||
for (final studyset in shelves[shelf] ?? const <Studyset>[]) studyset.id,
|
||||
];
|
||||
expect(ids(LibraryShelf.lastWeek), [
|
||||
'changed-today',
|
||||
'yesterday',
|
||||
'studied-3-days-ago',
|
||||
]);
|
||||
expect(ids(LibraryShelf.lastMonth), ['changed-2-weeks-ago']);
|
||||
expect(ids(LibraryShelf.older), ['old']);
|
||||
expect(shelves.keys, isNot(contains(LibraryShelf.starred)));
|
||||
});
|
||||
|
||||
test('the starred stand on their own shelf on top, however long ago', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
studyset('recent', updatedAt: now),
|
||||
studyset('starred-old', updatedAt: DateTime.utc(2025), starred: true),
|
||||
],
|
||||
progress: const {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(shelves.keys.first, LibraryShelf.starred);
|
||||
expect(shelves[LibraryShelf.starred]!.single.id, 'starred-old');
|
||||
expect(shelves[LibraryShelf.lastWeek]!.single.id, 'recent');
|
||||
});
|
||||
|
||||
test('a studyset from before changes were recorded counts from when it '
|
||||
'was made, its UUID v7 saying when', () {
|
||||
final shelves = shelvesOf(
|
||||
[
|
||||
// Made at 2026-11-08 00:00 UTC.
|
||||
studyset('01a1b874-6c00-7000-8000-000000000000'),
|
||||
studyset('not-a-uuid'),
|
||||
],
|
||||
progress: const {},
|
||||
now: now,
|
||||
);
|
||||
|
||||
expect(shelves[LibraryShelf.lastWeek]!.single.id, startsWith('01a1'));
|
||||
expect(shelves[LibraryShelf.older]!.single.id, 'not-a-uuid');
|
||||
});
|
||||
|
||||
test("the subjects in use, in the list's order; one without a subject, "
|
||||
'or one this app does not know, counts as Other', () {
|
||||
final studysets = [
|
||||
studyset('rust', subject: 'software-engineering'),
|
||||
studyset('japanese', subject: 'languages'),
|
||||
studyset('notes'),
|
||||
studyset('origami', subject: 'paper-folding'),
|
||||
studyset('korean', subject: 'languages'),
|
||||
];
|
||||
|
||||
expect(subjectsInUse(studysets), [
|
||||
Subject.softwareEngineering,
|
||||
Subject.languages,
|
||||
Subject.other,
|
||||
]);
|
||||
expect(
|
||||
[
|
||||
for (final studyset in studysets)
|
||||
if (filedUnder(studyset, Subject.other)) studyset.id,
|
||||
],
|
||||
['notes', 'origami'],
|
||||
);
|
||||
expect(filedUnder(studysets.first, Subject.softwareEngineering), isTrue);
|
||||
expect(filedUnder(studysets.first, Subject.languages), isFalse);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/device_settings/model/device_settings.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
|
|
@ -22,6 +23,7 @@ import 'package:material_ui/material_ui.dart';
|
|||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_publish_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/memory_device_settings_store.dart';
|
||||
import '../../../support/memory_published_store.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
|
@ -76,6 +78,7 @@ void main() {
|
|||
DateTime? starredAt,
|
||||
String? publishedAt,
|
||||
String? subject,
|
||||
DateTime? updatedAt,
|
||||
bool private = false,
|
||||
}) => gateway.receiveRemoteChange(
|
||||
id,
|
||||
|
|
@ -93,6 +96,7 @@ void main() {
|
|||
),
|
||||
archivedAt: archivedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
isPrivate: private,
|
||||
);
|
||||
|
||||
|
|
@ -373,6 +377,133 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("shelved and narrowed as Vaia's library (ADR 0041, D8, the "
|
||||
"developer's decisions of 10 Nov 2026)", () {
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
testWidgets('the starred on top, then the last 7 days, the last month '
|
||||
'and before, each under its heading; the archive last', (
|
||||
tester,
|
||||
) async {
|
||||
stored('old', 'Old', updatedAt: daysAgo(90));
|
||||
stored('recent', 'Recent', updatedAt: daysAgo(1));
|
||||
stored('weeks', 'Weeks', updatedAt: daysAgo(14));
|
||||
stored('star', 'Star', updatedAt: daysAgo(200), starredAt: daysAgo(1));
|
||||
stored('rest', 'Rest', updatedAt: daysAgo(2), archivedAt: daysAgo(2));
|
||||
await showLibrary(tester);
|
||||
double topOf(Finder finder) => tester.getTopLeft(finder).dy;
|
||||
|
||||
final order = [
|
||||
'Starred',
|
||||
'Star',
|
||||
'Last 7 days',
|
||||
'Recent',
|
||||
'Last month',
|
||||
'Weeks',
|
||||
'Older',
|
||||
'Old',
|
||||
'Archived',
|
||||
];
|
||||
for (final (before, after) in [
|
||||
for (var i = 1; i < order.length; i++) (order[i - 1], order[i]),
|
||||
]) {
|
||||
expect(
|
||||
topOf(find.text(before).first),
|
||||
lessThan(topOf(find.text(after).first)),
|
||||
reason: '$before above $after',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('the dropdown offers the subjects in use, narrows the '
|
||||
'library to one, and the device keeps it', (tester) async {
|
||||
final settings = MemoryDeviceSettingsStore();
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
stored('japanese', 'Japanese', subject: 'languages');
|
||||
stored('notes', 'Notes');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettingsStore: settings,
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('All subjects'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.widgetWithText(MenuItemButton, 'Software engineering'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.widgetWithText(MenuItemButton, 'Other / not sure'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Biology'), findsNothing, reason: 'not in use');
|
||||
await tester.tap(find.widgetWithText(MenuItemButton, 'Languages'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsNothing);
|
||||
expect(find.widgetWithText(StudysetCard, 'Notes'), findsNothing);
|
||||
expect(settings.writes.last.librarySubject, 'languages');
|
||||
|
||||
await tester.tap(find.byTooltip('Show one subject'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(MenuItemButton, 'Other / not sure'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(StudysetCard, 'Notes'), findsOneWidget);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('opens on the subject kept', (
|
||||
tester,
|
||||
) async {
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
stored('japanese', 'Japanese', subject: 'languages');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettings: const DeviceSettings(librarySubject: 'languages'),
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsNothing);
|
||||
expect(find.widgetWithText(StudysetCard, 'Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a subject kept but no longer in use shows all', (
|
||||
tester,
|
||||
) async {
|
||||
stored('rust', 'Rust', subject: 'software-engineering');
|
||||
tester.view.physicalSize = const Size(1200, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: gateway,
|
||||
cardGateway: cardGateway,
|
||||
deviceSettings: const DeviceSettings(librarySubject: 'biology'),
|
||||
),
|
||||
location: RoutePaths.library,
|
||||
);
|
||||
expect(find.widgetWithText(StudysetCard, 'Rust'), findsOneWidget);
|
||||
expect(find.text('All subjects'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('creating waits for a title', (tester) async {
|
||||
await showLibrary(tester);
|
||||
await tester.tap(find.text('Create studyset'));
|
||||
|
|
@ -482,9 +613,10 @@ void main() {
|
|||
|
||||
await showLibrary(tester);
|
||||
|
||||
// On a shelf of their own above (ADR 0041, D8).
|
||||
expect(
|
||||
tester.getTopLeft(find.text('German')).dx,
|
||||
lessThan(tester.getTopLeft(find.text('Japanese')).dx),
|
||||
tester.getTopLeft(find.text('German')).dy,
|
||||
lessThan(tester.getTopLeft(find.text('Japanese')).dy),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/theme/learn_theme.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/subject.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ void main() {
|
|||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 360,
|
||||
height: 188,
|
||||
height: 212,
|
||||
child: StudysetCard(
|
||||
studyset: studyset ?? vocabulary,
|
||||
progress: progress,
|
||||
|
|
@ -77,6 +78,47 @@ void main() {
|
|||
expect(find.text('52 flashcards'), findsOneWidget);
|
||||
});
|
||||
|
||||
group("names its subject under the title (ADR 0041, D8, the developer's "
|
||||
'decisions of 10 Nov 2026)', () {
|
||||
Studyset filed(String? subject, {String? front}) => Studyset(
|
||||
id: 'set',
|
||||
content: StudysetContent(
|
||||
title: 'Japanese',
|
||||
description: RichContent.empty(),
|
||||
subject: subject,
|
||||
frontLanguage: front,
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('with its icon', (tester) async {
|
||||
await show(tester, studyset: filed('software-engineering'));
|
||||
|
||||
final name = find.text('Software engineering');
|
||||
expect(name, findsOneWidget);
|
||||
expect(
|
||||
tester.getTopLeft(name).dy,
|
||||
greaterThan(tester.getBottomLeft(find.text('Japanese')).dy - 1),
|
||||
);
|
||||
expect(find.byIcon(Subject.softwareEngineering.icon), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a language course with its language', (tester) async {
|
||||
await show(tester, studyset: filed('languages', front: 'ja'));
|
||||
|
||||
expect(find.text('Languages · Japanese'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('none, nor one this app does not know: nothing', (
|
||||
tester,
|
||||
) async {
|
||||
await show(tester, studyset: filed(null));
|
||||
expect(find.byIcon(Subject.other.icon), findsNothing);
|
||||
|
||||
await show(tester, studyset: filed('paper-folding'));
|
||||
expect(find.byIcon(Subject.other.icon), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('says when a studyset holds nothing yet', (tester) async {
|
||||
await show(
|
||||
tester,
|
||||
|
|
|
|||
Loading…
Reference in a new issue