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
12 changed files with 786 additions and 26 deletions
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Reference in a new issue