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:
Tizian 「ティツィアーン」 2026-11-10 19:08:02 +01:00
commit a3b124ddcb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
19 changed files with 786 additions and 26 deletions

File diff suppressed because one or more lines are too long

View file

@ -2165,5 +2165,11 @@
"subjectOther": "Sonstiges / unsicher", "subjectOther": "Sonstiges / unsicher",
"studysetSheetSubject": "Fach", "studysetSheetSubject": "Fach",
"studysetSheetSubjectNone": "Fach wählen", "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"
} }

View file

@ -2656,5 +2656,17 @@
"studysetSheetSubjectNone": "Choose a subject", "studysetSheetSubjectNone": "Choose a subject",
"@studysetSheetSubjectNone": {"description": "The subject field before one is chosen."}, "@studysetSheetSubjectNone": {"description": "The subject field before one is chosen."},
"subjectPickerTitle": "Subject", "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."}
} }

View file

@ -12775,6 +12775,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Subject'** /// **'Subject'**
String get subjectPickerTitle; 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 class _AppLocalizationsDelegate

View file

@ -8170,4 +8170,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get subjectPickerTitle => 'Fach'; 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';
} }

View file

@ -8097,6 +8097,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get subjectPickerTitle => 'Subject'; 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`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -47,6 +47,9 @@ class DeviceSettingsBloc(
on<DeviceSettingsEventCheckGrammarChosen>( on<DeviceSettingsEventCheckGrammarChosen>(
(event, emit) => _apply(emit, state.withCheckGrammar(event.on)), (event, emit) => _apply(emit, state.withCheckGrammar(event.on)),
); );
on<DeviceSettingsEventLibrarySubjectChosen>(
(event, emit) => _apply(emit, state.withLibrarySubject(event.subject)),
);
on<DeviceSettingsEventSidebarChosen>( on<DeviceSettingsEventSidebarChosen>(
(event, emit) => _apply(emit, state.withSidebar(open: event.open)), (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]; 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]). /// The person opens the rail into the sidebar, or closes it ([open]).
final class const DeviceSettingsEventSidebarChosen(final bool open) final class const DeviceSettingsEventSidebarChosen(final bool open)
extends DeviceSettingsEvent { extends DeviceSettingsEvent {

View file

@ -78,6 +78,10 @@ class FileDeviceSettingsStore(final File _file) implements DeviceSettingsStore {
}, },
checkGrammar: json['checkGrammar'] != false, checkGrammar: json['checkGrammar'] != false,
sidebarOpen: json['sidebarOpen'] == true, sidebarOpen: json['sidebarOpen'] == true,
librarySubject: switch (json['librarySubject']) {
final String id => id,
_ => null,
},
); );
} on FormatException catch (error) { } on FormatException catch (error) {
log('The device settings are unreadable', name: 'Settings', error: 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, 'checkGrammar': settings.checkGrammar,
if (settings.sidebarOpen) 'sidebarOpen': true, if (settings.sidebarOpen) 'sidebarOpen': true,
'librarySubject': ?settings.librarySubject,
}), }),
flush: true, flush: true,
); );

View file

@ -67,6 +67,10 @@ final class const DeviceSettings({
/// (decided 10 Nov 2026), halkyon_app's `Look.sidebarOpen` kept here. /// (decided 10 Nov 2026), halkyon_app's `Look.sidebarOpen` kept here.
final bool sidebarOpen = false, 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 /// Whether the editors underline what the instance's LanguageTool finds
/// as the person writes (ADR 0024, Settings › Writing): on unless turned /// as the person writes (ADR 0024, Settings › Writing): on unless turned
/// off; nothing is checked where the instance checks nothing. /// 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. /// These settings with the sidebar [open] or closed.
DeviceSettings withSidebar({required bool open}) => _copy(sidebarOpen: open); 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. /// These settings with writing checked as it is written [on] or off.
DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on); DeviceSettings withCheckGrammar(bool on) => _copy(checkGrammar: on);
@ -136,6 +144,7 @@ final class const DeviceSettings({
Map<String, LlmEffort>? askEfforts, Map<String, LlmEffort>? askEfforts,
bool? checkGrammar, bool? checkGrammar,
bool? sidebarOpen, bool? sidebarOpen,
Object? librarySubject = _keep,
}) => DeviceSettings( }) => DeviceSettings(
appearance: appearance ?? this.appearance, appearance: appearance ?? this.appearance,
language: identical(language, _keep) ? this.language : language as String?, language: identical(language, _keep) ? this.language : language as String?,
@ -154,6 +163,9 @@ final class const DeviceSettings({
askEfforts: askEfforts ?? this.askEfforts, askEfforts: askEfforts ?? this.askEfforts,
checkGrammar: checkGrammar ?? this.checkGrammar, checkGrammar: checkGrammar ?? this.checkGrammar,
sidebarOpen: sidebarOpen ?? this.sidebarOpen, sidebarOpen: sidebarOpen ?? this.sidebarOpen,
librarySubject: identical(librarySubject, _keep)
? this.librarySubject
: librarySubject as String?,
); );
@override @override
@ -171,6 +183,7 @@ final class const DeviceSettings({
askEfforts, askEfforts,
checkGrammar, checkGrammar,
sidebarOpen, sidebarOpen,
librarySubject,
]; ];
} }

View file

@ -29,6 +29,10 @@ final class const StudyProgress({
/// How many files (PDFs) there are with the cards, counted as notes are. /// How many files (PDFs) there are with the cards, counted as notes are.
final int files = 0, 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 { }) with Equatable {
/// The share of mature cards, from 0 to 1; null without cards. /// The share of mature cards, from 0 to 1; null without cards.
double? get mastery => cards == 0 ? null : mature / cards; double? get mastery => cards == 0 ? null : mature / cards;
@ -47,6 +51,7 @@ final class const StudyProgress({
notes: notes, notes: notes,
paths: paths, paths: paths,
files: files, files: files,
lastStudied: lastStudied,
); );
@override @override
@ -58,6 +63,7 @@ final class const StudyProgress({
notes, notes,
paths, paths,
files, files,
lastStudied,
]; ];
} }
@ -88,5 +94,12 @@ StudyProgress studyProgress({
due: queue.length, due: queue.length,
reviewsDue: queue.where((id) => !(schedules[id]?.isNew ?? true)).length, reviewsDue: queue.where((id) => !(schedules[id]?.isNew ?? true)).length,
mature: cards.where((id) => schedules[id]?.isMature ?? false).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,
},
),
); );
} }

View file

@ -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,
];
}

View file

@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_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/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/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/widget/import_archive.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/model/study_progress.dart';
import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.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/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.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';
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/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/deleted_studyset_card.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_actions.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_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_navigation.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_sheet.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:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// The library tab: the person's studysets as cards in a grid, starred ones /// The library tab: the person's studysets as cards in a grid, on shelves
/// first, and archived ones in a section of their own below, closed until /// as Vaia's (ADR 0041, D8): the starred, then those studied or changed in
/// opened. A search above the grid finds studysets by title and secondary /// 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. /// title, archived ones too.
/// ///
/// Creating a studyset is a floating button in compact windows, which slides /// 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. // a row lines up.
static const _layout = SliverGridDelegateWithMaxCrossAxisExtent( static const _layout = SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400, maxCrossAxisExtent: 400,
mainAxisExtent: 188, mainAxisExtent: 212,
crossAxisSpacing: _spacing, crossAxisSpacing: _spacing,
mainAxisSpacing: _spacing, mainAxisSpacing: _spacing,
); );
@ -512,19 +519,34 @@ class _GridState() extends State<_Grid> {
_published = context.watch<PublishedCubit>().state; _published = context.watch<PublishedCubit>().state;
final query = widget.query.trim().toLowerCase(); final query = widget.query.trim().toLowerCase();
final searching = query.isNotEmpty; 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 = [ final active = [
for (final studyset in widget.studysets) for (final studyset in widget.studysets)
if (!studyset.isArchived && (!searching || _matches(studyset, query))) if (!studyset.isArchived && listed(studyset)) studyset,
studyset,
]; ];
final shelves = shelvesOf(
active,
progress: widget.progress,
now: clock.now().toUtc(),
);
final archived = [ final archived = [
for (final studyset in widget.studysets) for (final studyset in widget.studysets)
if (studyset.isArchived && (!searching || _matches(studyset, query))) if (studyset.isArchived && listed(studyset)) studyset,
studyset,
]; ];
final deleted = [ final deleted = [
for (final studyset in widget.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 // A search opens the sections, so their matches show; closing the
// search brings them back as the person left them. // search brings them back as the person left them.
@ -549,14 +571,26 @@ class _GridState() extends State<_Grid> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _PrivateOffer(studysets: widget.studysets), child: _PrivateOffer(studysets: widget.studysets),
), ),
SliverPadding( SliverToBoxAdapter(
padding: const EdgeInsets.all(_spacing), child: _SubjectDropdown(subjects: inUse, selected: subject),
sliver: SliverGrid.builder(
gridDelegate: _layout,
itemCount: active.length,
itemBuilder: (context, index) => _card(context, active[index]),
),
), ),
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) ...[ if (archived.isNotEmpty) ...[
SliverToBoxAdapter( SliverToBoxAdapter(
child: _SectionHeader( 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 /// A section's header (the archive, Recently deleted): it opens and closes
/// the section, and counts what rests there. /// the section, and counts what rests there.
class const _SectionHeader({ class const _SectionHeader({

View file

@ -1,17 +1,20 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.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/theme/learn_colors.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.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/publish/widget/share_button.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_progress.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.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_learn_flutter/src/feature/studyset/widget/studyset_actions.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// A studyset in the library. On top a bar in its colour; below, its title /// 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 /// 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 /// cards are due; at the bottom who made it, its star, whether it is
/// public and its options. /// public and its options.
@ -124,6 +127,14 @@ class _StudysetCardState() extends State<StudysetCard> {
onAdd: widget.onOpen, 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), const SizedBox(height: 8),
_Holds(progress: widget.progress), _Holds(progress: widget.progress),
], ],
@ -164,6 +175,41 @@ String _by(AppLocalizations l10n, Studyset studyset) {
return handle == null ? l10n.studysetByOthers : l10n.studysetByAuthor(handle); 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 /// What the studyset holds; a muted bar of the same height while that is
/// still being worked out, which "No content yet" would misstate. /// still being worked out, which "No content yet" would misstate.
class const _Holds({required final StudyProgress? progress}) class const _Holds({required final StudyProgress? progress})

View file

@ -65,6 +65,18 @@ void main() {
expect(read.withLanguage('de').sidebarOpen, isTrue); 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 { test('shows the Pomodoro timer while studying until told not to', () async {
expect((await store.read()).pomodoroWhileStudying, isTrue); expect((await store.read()).pomodoroWhileStudying, isTrue);

View file

@ -39,6 +39,46 @@ void main() {
expect(progress.mastery, 0.25); 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', () { test('counts only as many new cards as today still starts', () {
final order = [for (var i = 0; i < 30; i++) 'n$i']; final order = [for (var i = 0; i < 30; i++) 'n$i'];

View file

@ -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);
});
}

View file

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart'; import 'package:halkyon_app/testing.dart';
import 'package:halkyon_editor/halkyon_editor.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/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/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/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.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_card_gateway.dart';
import '../../../support/fake_publish_gateway.dart'; import '../../../support/fake_publish_gateway.dart';
import '../../../support/fake_studyset_gateway.dart'; import '../../../support/fake_studyset_gateway.dart';
import '../../../support/memory_device_settings_store.dart';
import '../../../support/memory_published_store.dart'; import '../../../support/memory_published_store.dart';
import '../../../support/pump_app.dart'; import '../../../support/pump_app.dart';
import '../../../support/test_dependencies.dart'; import '../../../support/test_dependencies.dart';
@ -76,6 +78,7 @@ void main() {
DateTime? starredAt, DateTime? starredAt,
String? publishedAt, String? publishedAt,
String? subject, String? subject,
DateTime? updatedAt,
bool private = false, bool private = false,
}) => gateway.receiveRemoteChange( }) => gateway.receiveRemoteChange(
id, id,
@ -93,6 +96,7 @@ void main() {
), ),
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
isPrivate: private, 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 { testWidgets('creating waits for a title', (tester) async {
await showLibrary(tester); await showLibrary(tester);
await tester.tap(find.text('Create studyset')); await tester.tap(find.text('Create studyset'));
@ -482,9 +613,10 @@ void main() {
await showLibrary(tester); await showLibrary(tester);
// On a shelf of their own above (ADR 0041, D8).
expect( expect(
tester.getTopLeft(find.text('German')).dx, tester.getTopLeft(find.text('German')).dy,
lessThan(tester.getTopLeft(find.text('Japanese')).dx), lessThan(tester.getTopLeft(find.text('Japanese')).dy),
); );
}); });

View file

@ -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/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.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.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:halkyon_learn_flutter/src/feature/studyset/widget/studyset_card.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -42,7 +43,7 @@ void main() {
body: Center( body: Center(
child: SizedBox( child: SizedBox(
width: 360, width: 360,
height: 188, height: 212,
child: StudysetCard( child: StudysetCard(
studyset: studyset ?? vocabulary, studyset: studyset ?? vocabulary,
progress: progress, progress: progress,
@ -77,6 +78,47 @@ void main() {
expect(find.text('52 flashcards'), findsOneWidget); 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 { testWidgets('says when a studyset holds nothing yet', (tester) async {
await show( await show(
tester, tester,

View file

@ -1,8 +1,8 @@
# ADR 0041: Learn's subjects: one per studyset, from a fixed list # ADR 0041: Learn's subjects: one per studyset, from a fixed list
- **Status:** decided 9 and 10 Nov 2026 by the developer: D1 to D4 and D6 are their own - **Status:** decided 9 and 10 Nov 2026 by the developer: D1 to D4 and D6 are their own
choices, D5 and D7 to D9 the agent's recommendations, which they confirmed. D1 to D7 choices, D5 and D7 to D9 and D8's details the agent's recommendations, which they
are built; D8 (the library) is next, and D10 later. confirmed. D1 to D9 are built; D10 comes later.
- **Builds on:** ADR 0002 (payloads and their schemas), ADR 0005 (publishing), ADR 0031 - **Builds on:** ADR 0002 (payloads and their schemas), ADR 0005 (publishing), ADR 0031
(the palette's twenty colours). (the palette's twenty colours).
@ -33,7 +33,17 @@ narrowed to one.
kept as it is and reads as no subject until another is picked. kept as it is and reads as no subject until another is picked.
- **D8.** The library, as Vaia's: a dropdown of the subjects in use, cards naming the - **D8.** The library, as Vaia's: a dropdown of the subjects in use, cards naming the
subject under the title with a stripe in its colour, sections by recency (the last 7 subject under the title with a stripe in its colour, sections by recency (the last 7
days, the last month, older), the archive last. days, the last month, older), the archive last. Decided on 10 Nov 2026:
- recent by the later of a card's last rating in the studyset (or inside it) and the
studyset's own last change; one from before changes were recorded counts from when
it was made, as its UUID v7 says (`lastUsedOf`, `shelvesOf`);
- the starred on a shelf of their own on top, however long ago they were used;
- the subject chosen kept on the device (`DeviceSettings.librarySubject`), all
subjects where none is chosen or the one kept is no longer in use; a studyset
without a subject, or with one this version doesn't know, is under *Other*;
- the stripe is the studyset's own colour, the card's bar on top, which the subject
sets when picked; the subject's line shows the subject's icon, and a language course
its language beside (*Languages · Japanese*).
- **D9.** Existing studysets: the agent writes a subject into each of the developer's 72 - **D9.** Existing studysets: the agent writes a subject into each of the developer's 72
archives not yet imported; those already in the library get one by hand. archives not yet imported; those already in the library get one by hand.
- **D10.** Explore filters by subject later; its releases don't keep the subject in a - **D10.** Explore filters by subject later; its releases don't keep the subject in a