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
12 changed files with 786 additions and 26 deletions

View file

@ -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"
}

View file

@ -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."}
}

View file

@ -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

View file

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

View file

@ -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`).

View file

@ -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 {

View file

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

View file

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

View file

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

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 '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({

View file

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