feat(studyset): the material in tabs, each saying how many it holds

The Material part stacked the flashcards, notes and files as sections,
the first three of each behind Show all, as Vaia stacks them. With
hundreds of cards that kept the list a tap away, repeated each list's
tools and pushed the files far down. They are tabs again, Flashcards,
Notes and Files, each with its count, each list whole with its tools at
the end of the tabs' row; Progress and the Study plan stay. The tab is
kept as the part the studyset was left on, while the material shows,
and opened for the first time it opens on a tab with something in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 20:21:09 +02:00
commit cf6d58aae7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
9 changed files with 336 additions and 219 deletions

View file

@ -1691,8 +1691,6 @@
"studysetPartMaterial": "Material",
"studysetPartProgress": "Fortschritt",
"studysetPartPlan": "Lernplan",
"studysetShowAll": "Alle zeigen",
"studysetShowFewer": "Weniger zeigen",
"progressLearned": "Gelernt",
"progressLearnedInfo": "Karten, die du drei Wochen oder länger behältst.",
"progressOfStudyset": "dieses Lernsets",

View file

@ -1993,8 +1993,6 @@
"studysetPartMaterial": "Material",
"studysetPartProgress": "Progress",
"studysetPartPlan": "Study plan",
"studysetShowAll": "Show all",
"studysetShowFewer": "Show fewer",
"progressLearned": "Learned",
"progressLearnedInfo": "Cards you will remember for three weeks or more.",
"progressOfStudyset": "of this studyset",

View file

@ -9987,18 +9987,6 @@ abstract class AppLocalizations {
/// **'Study plan'**
String get studysetPartPlan;
/// No description provided for @studysetShowAll.
///
/// In en, this message translates to:
/// **'Show all'**
String get studysetShowAll;
/// No description provided for @studysetShowFewer.
///
/// In en, this message translates to:
/// **'Show fewer'**
String get studysetShowFewer;
/// No description provided for @progressLearned.
///
/// In en, this message translates to:

View file

@ -6275,12 +6275,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get studysetPartPlan => 'Lernplan';
@override
String get studysetShowAll => 'Alle zeigen';
@override
String get studysetShowFewer => 'Weniger zeigen';
@override
String get progressLearned => 'Gelernt';

View file

@ -6220,12 +6220,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get studysetPartPlan => 'Study plan';
@override
String get studysetShowAll => 'Show all';
@override
String get studysetShowFewer => 'Show fewer';
@override
String get progressLearned => 'Learned';

View file

@ -27,6 +27,7 @@ import 'package:halkyon_learn_flutter/src/feature/study/widget/start_studying.da
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/content_tab_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_activity_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/bloc/studyset_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_tabs.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -157,26 +158,60 @@ Widget _pullToSync(
/// Room below a part for the bar floating over it.
const _foot = SliverToBoxAdapter(child: SizedBox(height: 96));
/// A studyset's material: its flashcards, notes and files, one section
/// below the other, each with its first few and Show all for the rest.
/// A studyset's material in tabs (decided 2026-10-12, after a trial of
/// sections one below the other): Flashcards, Notes and Files, each with
/// how many it holds, each list whole in its tab, its tools at the end of
/// the tabs' row (Browse and the tag filter for the flashcards, Reorder for
/// notes and files). Lists of hundreds of cards show whole at once, rather
/// than their first few behind Show all. The tab shown is kept as the
/// studyset's, on this device; opened here for the first time, it opens on
/// a tab with something in it, the flashcards if they have any, once the
/// lists are read. Material added shows in its tab.
class const MaterialPart({
required final String studysetId,
required final ScrollController scroll,
/// Whether the toolbar's buttons say what they do.
required final bool labelled,
/// Whether it is the part shown: only then is its tab kept, as the part
/// the studyset was left on.
required final bool shown,
super.key,
}) extends StatefulWidget {
@override
State<MaterialPart> createState() => _MaterialPartState();
}
class _MaterialPartState() extends State<MaterialPart> {
/// How many of each a section shows until it shows all.
static const _preview = 3;
class _MaterialPartState()
extends State<MaterialPart>
with SingleTickerProviderStateMixin {
/// The material's tabs, in order.
static const _tabs = [
StudysetTab.flashcards,
StudysetTab.notes,
StudysetTab.files,
];
/// The sections showing all they hold.
final _expanded = <StudysetTab>{};
/// The tab each studyset was left on, on this device.
late final StudysetTabs _kept = PersonScope.of(context).studysetTabs;
/// The tab it was left on, if any.
late final StudysetTab? _keptTab = switch (StudysetTab.values
.asNameMap()[_kept.of(widget.studysetId)]) {
final tab? when _tabs.contains(tab) => tab,
_ => null,
};
late final _controller = TabController(
length: _tabs.length,
vsync: this,
initialIndex: _tabs.indexOf(_keptTab ?? StudysetTab.flashcards),
);
/// Whether the tab to show is settled: the one it was left on, one
/// chosen, or the one with content, once the lists are read.
late var _settled = _keptTab != null;
/// Whether the notes show handles to drag them.
var _reorderingNotes = false;
@ -184,11 +219,85 @@ class _MaterialPartState() extends State<MaterialPart> {
/// Whether the files show handles to drag them, in their own order.
var _reorderingFiles = false;
int? _limit(StudysetTab tab) => _expanded.contains(tab) ? null : _preview;
/// The tab chosen for the content, until the tabs move to it.
StudysetTab? _moving;
void _toggle(StudysetTab tab) => setState(
() => _expanded.contains(tab) ? _expanded.remove(tab) : _expanded.add(tab),
);
StudysetTab get _shown => _moving ?? _tabs[_controller.index];
@override
void initState() {
super.initState();
_controller.addListener(() {
if (!_controller.indexIsChanging) {
setState(() => _settled = true);
_keepTab();
}
});
}
@override
void didUpdateWidget(MaterialPart oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.shown && !oldWidget.shown) {
_keepTab();
}
}
/// Keeps the tab shown as the one the studyset was left on, while the
/// material is the part shown; it says the part too.
void _keepTab() {
if (widget.shown && _settled) {
unawaited(_kept.keep(widget.studysetId, _shown.name));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
/// The tab shown tapped again goes back to its top.
void _tapped(int index) {
if (index == _controller.index && widget.scroll.hasClients) {
widget.scroll.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
}
/// Opened here for the first time, the tab with something in it, once
/// the lists are read: the flashcards if they have any, else the notes,
/// else the files, else the flashcards.
void _settleOnContent({
required int? cards,
required int? notes,
required int? files,
}) {
if (_settled || cards == null || notes == null || files == null) {
return;
}
_settled = true;
final tab = switch ((cards, notes, files)) {
(0, > 0, _) => StudysetTab.notes,
(0, 0, > 0) => StudysetTab.files,
_ => StudysetTab.flashcards,
};
if (tab == _shown) {
_keepTab();
return;
}
// Shown at once; the tabs follow once this frame is built.
_moving = tab;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_moving = null;
_controller.index = _tabs.indexOf(tab);
}
});
}
@override
Widget build(BuildContext context) {
@ -196,9 +305,105 @@ class _MaterialPartState() extends State<MaterialPart> {
final cards = context.watch<CardListBloc>().state;
final notes = context.watch<NoteListBloc>().state;
final files = context.watch<PdfListBloc>().state;
const sidePadding = EdgeInsets.symmetric(horizontal: 12);
final cardCount = switch (cards) {
CardListStateLoaded(:final cards) => cards.length,
_ => null,
};
final noteCount = notes.loaded ? notes.notes.length : null;
final fileCount = files.loaded ? files.rows.length : null;
_settleOnContent(cards: cardCount, notes: noteCount, files: fileCount);
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
final tools = switch (_shown) {
StudysetTab.flashcards => [
BrowseCardsButton(
studysetId: widget.studysetId,
labelled: widget.labelled,
),
if (cards case CardListStateLoaded(cards: [_, ...]))
const CardListToolbar(),
],
StudysetTab.notes => [
if (notes.notes.length >= 2 || _reorderingNotes)
ReorderToggle(
reordering: _reorderingNotes,
labelled: widget.labelled,
tooltip: l10n.studysetReorderNotes,
onToggle: () =>
setState(() => _reorderingNotes = !_reorderingNotes),
),
],
_ => [
if (files.rows.length >= 2 || _reorderingFiles)
ReorderToggle(
reordering: _reorderingFiles,
labelled: widget.labelled,
tooltip: l10n.studysetReorderFiles,
onToggle: () {
if (!_reorderingFiles) {
// What is dragged is the person's own order, whatever the
// files were sorted by.
context.read<PdfListBloc>().add(
const PdfListEventSorted(PdfSort.custom),
);
}
setState(() => _reorderingFiles = !_reorderingFiles);
},
),
],
};
final body = !_settled
? const _SliverSectionLoading()
: switch (_shown) {
StudysetTab.flashcards => switch (cards) {
CardListStateLoading() => const _SliverSectionLoading(),
CardListStateLoaded(cards: []) => _SliverEmptySection(
icon: LucideIcons.layers,
title: l10n.studysetFlashcardsEmpty,
hint: l10n.studysetFlashcardsEmptyHint,
),
_ => SliverCardList(
cards: PersonScope.of(context).cards,
studysetId: widget.studysetId,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
),
},
StudysetTab.notes => switch (notes) {
NoteListState(loaded: false) => const _SliverSectionLoading(),
NoteListState(notes: [], failed: false) => _SliverEmptySection(
icon: LucideIcons.notebookPen,
title: l10n.notesEmpty,
hint: l10n.notesEmptyHint,
),
_ => SliverNoteList(
studysetId: widget.studysetId,
reordering: _reorderingNotes,
padding: sidePadding,
),
},
_ => switch (files) {
PdfListState(loaded: false) => const _SliverSectionLoading(),
PdfListState(rows: [], failed: false) => _SliverEmptySection(
icon: LucideIcons.folder,
title: l10n.filesEmpty,
hint: l10n.filesEmptyHint,
),
_ => SliverPdfList(
studysetId: widget.studysetId,
reordering: _reorderingFiles,
padding: sidePadding,
),
},
};
return MultiBlocListener(
listeners: [
// Material added or imported shows in its tab.
BlocListener<ContentTabCubit, ContentTabState>(
listener: (context, request) {
if (request case (:final tab, serial: _) when _tabs.contains(tab)) {
_controller.animateTo(_tabs.indexOf(tab));
}
},
),
BlocListener<NoteListBloc, NoteListState>(
listener: (context, state) {
// Nothing left to reorder.
@ -215,184 +420,66 @@ class _MaterialPartState() extends State<MaterialPart> {
},
),
],
child: _pullToSync(context, widget.scroll, [
const SliverToBoxAdapter(child: SizedBox(height: 4)),
..._section(
tab: StudysetTab.flashcards,
title: l10n.studysetTabFlashcards,
count: switch (cards) {
CardListStateLoaded(:final cards) => cards.length,
_ => null,
},
shown: switch (cards) {
CardListStateLoaded(:final shown) => shown.length,
_ => 0,
},
actions: [
BrowseCardsButton(
studysetId: widget.studysetId,
labelled: widget.labelled,
),
// Narrowing them to a tag where they all show, or while they
// are narrowed: otherwise the title keeps its room on a phone.
if (_expanded.contains(StudysetTab.flashcards) ||
switch (cards) {
CardListStateLoaded(:final shown, :final filter) =>
shown.length <= _preview || filter != null,
_ => false,
})
const CardListToolbar(),
],
body: switch (cards) {
CardListStateLoading() => const _SliverSectionLoading(),
CardListStateLoaded(cards: []) => _SliverEmptySection(
icon: LucideIcons.layers,
title: l10n.studysetFlashcardsEmpty,
hint: l10n.studysetFlashcardsEmptyHint,
),
_ => SliverCardList(
cards: PersonScope.of(context).cards,
studysetId: widget.studysetId,
limit: _limit(StudysetTab.flashcards),
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
),
},
),
..._section(
tab: StudysetTab.notes,
title: l10n.studysetTabNotes,
count: notes.loaded ? notes.notes.length : null,
shown: notes.notes.length,
reorderable: (
reordering: _reorderingNotes,
tooltip: l10n.studysetReorderNotes,
onToggle: () => setState(() {
_reorderingNotes = !_reorderingNotes;
_expanded.add(StudysetTab.notes);
}),
),
body: switch (notes) {
NoteListState(loaded: false) => const _SliverSectionLoading(),
NoteListState(notes: [], failed: false) => _SliverEmptySection(
icon: LucideIcons.notebookPen,
title: l10n.notesEmpty,
hint: l10n.notesEmptyHint,
),
_ => SliverNoteList(
studysetId: widget.studysetId,
reordering: _reorderingNotes,
limit: _reorderingNotes ? null : _limit(StudysetTab.notes),
padding: sidePadding,
),
},
),
..._section(
tab: StudysetTab.files,
title: l10n.studysetTabFiles,
count: files.loaded ? files.rows.length : null,
shown: files.rows.length,
reorderable: (
reordering: _reorderingFiles,
tooltip: l10n.studysetReorderFiles,
onToggle: () {
if (!_reorderingFiles) {
// What is dragged is the person's own order, whatever the
// files were sorted by.
context.read<PdfListBloc>().add(
const PdfListEventSorted(PdfSort.custom),
);
}
setState(() {
_reorderingFiles = !_reorderingFiles;
_expanded.add(StudysetTab.files);
});
},
),
body: switch (files) {
PdfListState(loaded: false) => const _SliverSectionLoading(),
PdfListState(rows: [], failed: false) => _SliverEmptySection(
icon: LucideIcons.folder,
title: l10n.filesEmpty,
hint: l10n.filesEmptyHint,
),
_ => SliverPdfList(
studysetId: widget.studysetId,
reordering: _reorderingFiles,
limit: _reorderingFiles ? null : _limit(StudysetTab.files),
padding: sidePadding,
),
},
),
_foot,
]),
);
}
/// A section: its [title] with how many it holds ([count], once known),
/// its [actions], Reorder where [reorderable] and it holds two or more,
/// and Show all while it [shown] more than the first few; then [body].
List<Widget> _section({
required StudysetTab tab,
required String title,
required int? count,
required int shown,
required Widget body,
List<Widget> actions = const [],
({bool reordering, String tooltip, VoidCallback onToggle})? reorderable,
}) {
final l10n = AppLocalizations.of(context);
final expanded = _expanded.contains(tab);
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
child: Row(
children: [
// The title takes what the buttons leave, so they end at the
// section's edge; a Spacer beside a Flexible title would share
// that room with it and leave them halfway.
Expanded(
child: Row(
children: [
Flexible(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
),
),
if (count != null) ...[
const SizedBox(width: 8),
_CountBadge(count: count),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
children: [
Expanded(
child: TabBar(
controller: _controller,
onTap: _tapped,
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
// The line below runs the whole width instead.
dividerColor: Colors.transparent,
tabs: [
_TabLabel(l10n.studysetTabFlashcards, cardCount),
_TabLabel(l10n.studysetTabNotes, noteCount),
_TabLabel(l10n.studysetTabFiles, fileCount),
],
],
),
),
...actions,
if (reorderable != null &&
((count ?? 0) >= 2 || reorderable.reordering))
ReorderToggle(
reordering: reorderable.reordering,
labelled: widget.labelled,
tooltip: reorderable.tooltip,
onToggle: reorderable.onToggle,
),
if (shown > _preview && reorderable?.reordering != true)
TextButton(
onPressed: () => _toggle(tab),
child: Text(
expanded ? l10n.studysetShowFewer : l10n.studysetShowAll,
),
),
],
...tools,
],
),
),
),
const Divider(height: 1),
Expanded(
// A list of its own for each tab, from its top.
child: KeyedSubtree(
key: ValueKey(_shown),
child: _pullToSync(context, widget.scroll, [body, _foot]),
),
),
],
),
body,
];
);
}
}
/// A material's tab: its [title] and how many it holds ([count], once
/// known).
class const _TabLabel(final String title, final int? count)
extends StatelessWidget {
@override
Widget build(BuildContext context) => Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(title),
if (count case final count?) ...[
const SizedBox(width: 6),
_CountBadge(count: count),
],
],
),
);
}
/// How many a section holds, in a small box beside its title.
class const _CountBadge({required final int count}) extends StatelessWidget {
@override

View file

@ -760,6 +760,18 @@ class _PartsState() extends State<_Parts> {
super.dispose();
}
/// Keeps [part] as the one the studyset was left on, unless what is kept
/// says it already: the material's tab, which the material keeps, is in
/// the material.
void _keep(StudysetPart part) {
final kept = _kept.of(widget.studysetId);
if ((StudysetPart.values.asNameMap()[kept] ??
StudysetTab.values.asNameMap()[kept]?.part) !=
part) {
unawaited(_kept.keep(widget.studysetId, part.name));
}
}
/// Shows [part], kept for the next time; chosen again, it goes back to
/// its top.
void _show(StudysetPart part) {
@ -775,7 +787,7 @@ class _PartsState() extends State<_Parts> {
_part = part;
_settled = true;
});
unawaited(_kept.keep(widget.studysetId, part.name));
_keep(part);
}
/// Chooses the part to open on, once the paths are known: the plan while
@ -796,7 +808,7 @@ class _PartsState() extends State<_Parts> {
_part = part;
_settled = true;
});
unawaited(_kept.keep(widget.studysetId, part.name));
_keep(part);
}
@override
@ -863,6 +875,7 @@ class const _Content({
studysetId: studysetId,
scroll: parts.scrolls[StudysetPart.material]!,
labelled: labelled,
shown: parts.shown == StudysetPart.material,
),
ProgressPart(
studysetId: studysetId,

View file

@ -150,7 +150,13 @@ void main() {
expect(tabs.kept, {'japanese': 'plan'});
await openPart(tester, 'Material');
expect(tabs.kept, {'japanese': 'material'});
// The material's tab, which says the part too.
expect(tabs.kept, {'japanese': 'flashcards'});
await tester.tap(find.widgetWithText(Tab, 'Notes'));
await tester.pumpAndSettle();
expect(tabs.kept, {'japanese': 'notes'});
await openPart(tester, 'Study plan');
expect(tabs.kept, {'japanese': 'plan'});
});
testWidgets('a studyset with only notes opens on them', (tester) async {

View file

@ -613,33 +613,41 @@ void main() {
stored('japanese', 'Japanese');
await openFromLibrary(tester, 'Japanese');
for (final (part, icon, title, how) in [
for (final (part, tab, icon, title, how) in [
(
'Material',
'Flashcards',
LucideIcons.layers,
'No flashcards yet',
'"Add material → Create flashcards"',
),
(
'Material',
'Notes',
LucideIcons.notebookPen,
'No notes yet',
'"Add material → Create a note"',
),
(
'Material',
'Files',
LucideIcons.folder,
'No files yet',
'"Add material → Upload a PDF"',
),
(
'Study plan',
null,
LucideIcons.route,
'No paths yet',
'"Add material → Create a path"',
),
]) {
await openPart(tester, part);
if (tab != null) {
await tester.tap(find.widgetWithText(Tab, tab));
await tester.pumpAndSettle();
}
// The box that says so, around its icon, title and hint.
final box = find.ancestor(
of: find.text(title),
@ -1493,6 +1501,39 @@ void main() {
);
});
testWidgets("the material's tabs say how many each holds, and each "
'holds its whole list', (tester) async {
stored('japanese', 'Japanese');
for (final front in ['猫', '犬', '鳥', '魚', '馬']) {
await card('japanese', front);
}
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.empty()),
);
await openFromLibrary(tester, 'Japanese');
await openPart(tester, 'Material');
String? countOn(String tab) => tester
.widgetList<Text>(
find.descendant(
of: find.widgetWithText(Tab, tab),
matching: find.byType(Text),
),
)
.map((text) => text.data)
.where((data) => data != tab)
.singleOrNull;
expect(countOn('Flashcards'), '5');
expect(countOn('Notes'), '1');
expect(countOn('Files'), '0');
// All five, no Show all.
expect(find.text('馬'), findsOneWidget);
expect(find.text('Show all'), findsNothing);
});
testWidgets('tapping the open part again goes back to its top', (
tester,
) async {
@ -1506,13 +1547,11 @@ void main() {
);
}
await openFromLibrary(tester, 'Japanese');
// On screen, not merely built.
// On screen, not merely built; on the notes' tab, the only one with
// anything in it.
final first = find.text('Note 1').hitTestable();
expect(first, findsOneWidget);
// The first three, until all show.
expect(find.text('Note 4'), findsNothing);
await tester.tap(find.text('Show all'));
await tester.pumpAndSettle();
expect(find.text('Note 4'), findsOneWidget);
await tester.drag(find.text('Note 3'), const Offset(0, -600));
await tester.pumpAndSettle();