fix(studyset): the material's tools fold into the menu rather than cutting the tabs off

Where the row beside the material's tabs ran short, Browse, the filter
and the menu kept their room and the tabs were cut off ("Fil…"). The
tools now fold one by one, from the widest to the narrowest that leaves
the tabs their whole width: Browse loses its words first, then the
filter goes into the ⋮ menu, then Browse does too, as the studyset's own
actions fold into icons. A row of its own (CollapsingRow) measures the
tabs and each way of showing the tools and shows the first that fits.

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:54:55 +02:00
commit 468810c6f4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 353 additions and 119 deletions

View file

@ -0,0 +1,175 @@
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
/// A row of `leading` (tabs, a title) and, at its end, the first of
/// `levels` that leaves `leading` all the width it would take: the levels
/// are the same tools from the widest (every button with its label) to
/// the narrowest (all of them in a ⋮ menu), so where room runs short the
/// tools fold one by one rather than cutting `leading` off. `leading`
/// takes what the level chosen leaves, its natural width at most; the
/// level stands at the row's end. Only the level chosen shows; the others
/// are kept, unseen and untouchable, so their state stays.
// Not const: its children are a list made of the leading child and the
// levels.
// ignore: prefer_const_constructors_in_immutables
class CollapsingRow({
required Widget leading,
required List<Widget> levels,
super.key,
}) extends MultiChildRenderObjectWidget {
this
: assert(levels.isNotEmpty, 'There is always a level to choose'),
super(children: [leading, ...levels]);
@override
RenderObject createRenderObject(BuildContext context) =>
RenderCollapsingRow();
}
/// Lays out a [CollapsingRow]: its first child the leading one, the rest
/// the levels, widest first.
class RenderCollapsingRow()
extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _RowParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
/// The level shown, by its index among the levels.
@visibleForTesting
int chosen = 0;
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _RowParentData) {
child.parentData = _RowParentData();
}
}
List<RenderBox> get _children => getChildrenAsList();
RenderBox get _leading => _children.first;
List<RenderBox> get _levels => _children.sublist(1);
/// The widest level leaving the leading child its natural width in
/// [width]; the narrowest when none does.
int _choose(double width) {
final need = _leading.getMaxIntrinsicWidth(double.infinity);
final levels = _levels;
for (final (index, level) in levels.indexed) {
if (need + level.getMaxIntrinsicWidth(double.infinity) <= width) {
return index;
}
}
return levels.length - 1;
}
@override
double computeMinIntrinsicWidth(double height) =>
_levels.last.getMinIntrinsicWidth(height);
@override
double computeMaxIntrinsicWidth(double height) =>
_leading.getMaxIntrinsicWidth(height) +
_levels.first.getMaxIntrinsicWidth(height);
@override
double computeMinIntrinsicHeight(double width) => _children.fold(
0,
(most, child) => math.max(most, child.getMinIntrinsicHeight(width)),
);
@override
double computeMaxIntrinsicHeight(double width) => _children.fold(
0,
(most, child) => math.max(most, child.getMaxIntrinsicHeight(width)),
);
@override
Size computeDryLayout(BoxConstraints constraints) {
final width = constraints.maxWidth;
final level = _levels[_choose(width)].getDryLayout(
BoxConstraints(maxHeight: constraints.maxHeight),
);
final leading = _leading.getDryLayout(
BoxConstraints(
maxWidth: math.max(0, width - level.width),
maxHeight: constraints.maxHeight,
),
);
return constraints.constrain(
Size(width, math.max(level.height, leading.height)),
);
}
@override
void performLayout() {
final width = constraints.maxWidth;
chosen = _choose(width);
final levels = _levels;
for (final (index, level) in levels.indexed) {
level.layout(
index == chosen
? BoxConstraints(maxHeight: constraints.maxHeight)
: BoxConstraints.tight(Size.zero),
parentUsesSize: true,
);
}
final level = levels[chosen];
final leading = _leading
..layout(
BoxConstraints(
maxWidth: math.max(0, width - level.size.width),
maxHeight: constraints.maxHeight,
),
parentUsesSize: true,
);
size = constraints.constrain(
Size(width, math.max(level.size.height, leading.size.height)),
);
(leading.parentData! as _RowParentData).offset = Offset(
0,
(size.height - leading.size.height) / 2,
);
(level.parentData! as _RowParentData).offset = Offset(
size.width - level.size.width,
(size.height - level.size.height) / 2,
);
}
/// The children that show: the leading one and the level chosen.
Iterable<RenderBox> get _shown => [_leading, _levels[chosen]];
@override
void paint(PaintingContext context, Offset offset) {
for (final child in _shown) {
context.paintChild(
child,
(child.parentData! as _RowParentData).offset + offset,
);
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
for (final child in _shown.toList().reversed) {
final offset = (child.parentData! as _RowParentData).offset;
if (result.addWithPaintOffset(
offset: offset,
position: position,
hitTest: (result, transformed) =>
child.hitTest(result, position: transformed),
)) {
return true;
}
}
return false;
}
@override
void visitChildrenForSemantics(RenderObjectVisitor visitor) =>
_shown.forEach(visitor);
}
class _RowParentData() extends ContainerBoxParentData<RenderBox>;

View file

@ -113,9 +113,19 @@ class const SliverCardList({
);
}
/// Beside the cards: narrowing them to a tag, and managing the tags; from
/// the [CardListScope] above, once there are cards.
class const CardListToolbar({super.key}) extends StatelessWidget {
/// Beside the cards: Browse ([onBrowse]), narrowing them to a tag, and a
/// menu managing the tags; from the [CardListScope] above, once there are
/// cards. Where room runs short they fold into the menu one by one, as far
/// as [folded] says: 0 all in the row, Browse with its label; 1 Browse an
/// icon; 2 the filter in the menu; 3 Browse in the menu too.
class const CardListToolbar({
required final VoidCallback onBrowse,
final int folded = 0,
super.key,
}) extends StatelessWidget {
/// How far the tools fold at most.
static const mostFolded = 3;
@override
Widget build(BuildContext context) {
final state = context.watch<CardListBloc>().state;
@ -124,32 +134,59 @@ class const CardListToolbar({super.key}) extends StatelessWidget {
}
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// Tinted while it narrows the list.
final filterColor = state.filter == null ? null : theme.colorScheme.primary;
Future<void> filter() async {
final bloc = context.read<CardListBloc>();
final chosen = await showAdaptiveSheet<({String? tagId})>(
context,
builder: (context) =>
_FilterSheet(tags: state.tagsInUse, tagId: state.filter?.id),
);
if (chosen != null) {
bloc.add(CardListEventTagFilterChanged(chosen.tagId));
}
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: l10n.cardFilterTooltip,
icon: Icon(
LucideIcons.listFilter,
// Tinted while it narrows the list.
color: state.filter == null ? null : theme.colorScheme.primary,
if (folded == 0)
TextButton.icon(
onPressed: onBrowse,
icon: const Icon(LucideIcons.layoutGrid, size: 18),
label: Text(l10n.cardBrowserBrowse),
)
else if (folded < mostFolded)
IconButton(
onPressed: onBrowse,
icon: const Icon(LucideIcons.layoutGrid),
tooltip: l10n.cardBrowserBrowseTooltip,
),
if (folded < 2)
IconButton(
tooltip: l10n.cardFilterTooltip,
icon: Icon(LucideIcons.listFilter, color: filterColor),
onPressed: filter,
),
onPressed: () async {
final bloc = context.read<CardListBloc>();
final chosen = await showAdaptiveSheet<({String? tagId})>(
context,
builder: (context) => _FilterSheet(
tags: state.tagsInUse,
tagId: state.filter?.id,
),
);
if (chosen != null) {
bloc.add(CardListEventTagFilterChanged(chosen.tagId));
}
},
),
MenuAnchor(
menuChildren: [
if (folded >= mostFolded)
MenuItemButton(
leadingIcon: const Icon(LucideIcons.layoutGrid, size: 18),
onPressed: onBrowse,
child: Text(l10n.cardBrowserBrowse),
),
if (folded >= 2)
MenuItemButton(
leadingIcon: Icon(
LucideIcons.listFilter,
size: 18,
color: filterColor,
),
onPressed: filter,
child: Text(l10n.cardFilterTooltip),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.tags, size: 18),
onPressed: () => showManageTagsSheet(context),

View file

@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/sync/bloc/sync_status_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/widget/sync_and_wait.dart';
import 'package:halkyon_learn_flutter/src/core/theme/theme_extras.dart';
import 'package:halkyon_learn_flutter/src/core/widget/app_loading_indicator.dart';
import 'package:halkyon_learn_flutter/src/core/widget/collapsing_row.dart';
import 'package:halkyon_learn_flutter/src/core/widget/reorder_toggle.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
@ -257,6 +258,18 @@ class _MaterialPartState()
super.dispose();
}
/// Opens the card browser on the flashcards, with those of the studysets
/// inside it where it has any, as the rail lists them.
void _browse(BuildContext context) => context.push(
RoutePaths.cardBrowser(
widget.studysetId,
subtree: switch (context.read<StudysetListBloc>().state) {
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
_ => false,
},
),
);
/// The tab shown tapped again goes back to its top.
void _tapped(int index) {
if (index == _controller.index && widget.scroll.hasClients) {
@ -313,43 +326,47 @@ class _MaterialPartState()
final fileCount = files.loaded ? files.rows.length : null;
_settleOnContent(cards: cardCount, notes: noteCount, files: fileCount);
const sidePadding = EdgeInsets.fromLTRB(12, 8, 12, 0);
void reorderFiles() {
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);
}
// The tab's tools from the widest to the narrowest, the first leaving
// the tabs their whole width shown (CollapsingRow).
final tools = switch (_shown) {
StudysetTab.flashcards => [
BrowseCardsButton(
studysetId: widget.studysetId,
labelled: widget.labelled,
),
if (cards case CardListStateLoaded(cards: [_, ...]))
const CardListToolbar(),
for (var folded = 0; folded <= CardListToolbar.mostFolded; folded++)
CardListToolbar(
onBrowse: () => _browse(context),
folded: folded,
),
],
StudysetTab.notes => [
if (notes.notes.length >= 2 || _reorderingNotes)
StudysetTab.notes when notes.notes.length >= 2 || _reorderingNotes => [
for (final labelled in [true, false])
ReorderToggle(
reordering: _reorderingNotes,
labelled: widget.labelled,
labelled: labelled,
tooltip: l10n.studysetReorderNotes,
onToggle: () =>
setState(() => _reorderingNotes = !_reorderingNotes),
),
],
_ => [
if (files.rows.length >= 2 || _reorderingFiles)
StudysetTab.files when files.rows.length >= 2 || _reorderingFiles => [
for (final labelled in [true, false])
ReorderToggle(
reordering: _reorderingFiles,
labelled: widget.labelled,
labelled: 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);
},
onToggle: reorderFiles,
),
],
_ => const [SizedBox.shrink()],
};
final body = !_settled
? const _SliverSectionLoading()
@ -425,26 +442,27 @@ class _MaterialPartState()
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),
],
),
),
...tools,
],
// The tabs keep their whole width; the tools fold where room
// runs short.
child: CollapsingRow(
leading: TabBar(
controller: _controller,
onTap: _tapped,
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
// Closer than Material's 16 a side, so the three tabs with
// their counts fit a narrow phone beside the menu.
labelPadding: const EdgeInsets.symmetric(horizontal: 12),
// The line below runs the whole width instead.
dividerColor: Colors.transparent,
tabs: [
_TabLabel(l10n.studysetTabFlashcards, cardCount),
_TabLabel(l10n.studysetTabNotes, noteCount),
_TabLabel(l10n.studysetTabFiles, fileCount),
],
),
levels: tools,
),
),
const Divider(height: 1),
@ -558,50 +576,6 @@ class const _SliverEmptySection({
}
}
/// Browse beside the flashcards, while there are cards: opens the card
/// browser of the studyset [studysetId], over everything inside it too
/// when there is something. [labelled] says Browse, else an icon alone.
class const BrowseCardsButton({
required final String studysetId,
required final bool labelled,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasCards = context.select<CardListBloc, bool>(
(bloc) => switch (bloc.state) {
CardListStateLoaded(:final cards) => cards.isNotEmpty,
_ => false,
},
);
if (!hasCards) {
return const SizedBox.shrink();
}
final l10n = AppLocalizations.of(context);
void open() => context.push(
RoutePaths.cardBrowser(
studysetId,
// The rail's list: the studysets inside this one.
subtree: switch (context.read<StudysetListBloc>().state) {
StudysetListStateLoaded(:final studysets) => studysets.isNotEmpty,
_ => false,
},
),
);
return labelled
? TextButton.icon(
onPressed: open,
icon: const Icon(LucideIcons.layoutGrid, size: 18),
label: Text(l10n.cardBrowserBrowse),
)
: IconButton(
onPressed: open,
icon: const Icon(LucideIcons.layoutGrid),
tooltip: l10n.cardBrowserBrowseTooltip,
);
}
}
/// The studyset [studysetId]'s progress, with everything inside it: how much
/// is learnt, what is due today, and how long it was studied this week;
/// nothing yet where nothing was studied.

View file

@ -1534,6 +1534,50 @@ void main() {
expect(find.text('Show all'), findsNothing);
});
testWidgets("the material's tools fold into the menu one by one rather "
'than cutting the tabs off: Browse loses its words first', (
tester,
) async {
stored('japanese', 'Japanese');
await card('japanese', '猫');
final seen = <String>{};
for (final width in <double>[360, 420, 600, 760, 860, 1000, 1200, 1600]) {
await showApp(
tester,
location: RoutePaths.studyset('japanese'),
width: width,
);
await tester.pumpAndSettle();
await openPart(tester, 'Material');
final tabs = tester.getRect(find.byType(TabBar));
final files = tester.getRect(find.widgetWithText(Tab, 'Files'));
// What shows: the levels folded away are there, but unseen.
bool shows(Finder finder) => finder.hitTestable().evaluate().isNotEmpty;
final level = [
if (shows(find.text('Browse'))) 'words',
if (shows(find.byTooltip('Browse flashcards'))) 'icon',
if (shows(find.byTooltip('Filter'))) 'filter',
].join('+');
seen.add(level);
// The tabs are cut only once everything is folded away (the test's
// font is wider than the app's, so narrower windows get there).
if (files.right > tabs.right) {
expect(level, isEmpty, reason: '$width');
}
if (level.isEmpty) {
// Browse in the menu.
await tester.tap(find.byTooltip('More').hitTestable());
await tester.pumpAndSettle();
expect(find.text('Browse').hitTestable(), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
}
}
// The widest windows show the words, the narrowest folds them away.
expect(seen, contains('words+filter'));
expect(seen, contains(''));
});
testWidgets('tapping the open part again goes back to its top', (
tester,
) async {
@ -1714,11 +1758,13 @@ void main() {
)
.first,
);
// Browse, then the filter and the menu, the last at the edge.
expect(
tester.getRect(find.byType(CardListToolbar)).right,
greaterThan(material.right - 16),
);
// Browse, then the filter and the menu, the last at the edge: the
// tools as they show, the folded ways of showing them unseen.
final shown = [
for (final toolbar in find.byType(CardListToolbar).evaluate())
tester.getRect(find.byElementPredicate((each) => each == toolbar)),
].where((rect) => rect.width > 0).single;
expect(shown.right, greaterThan(material.right - 16));
});
testWidgets('Progress is always there, nothing yet before anything is '

View file

@ -98,7 +98,9 @@ void main() {
/// Opens the tags sheet from the card list's menu.
Future<void> manageTags(WidgetTester tester) async {
await tester.tap(find.byTooltip('More'));
// The menu as it shows; the folded ways of showing the tools are there
// too, unseen.
await tester.tap(find.byTooltip('More').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Manage tags'));
// A menu item acts once the menu has closed, a frame later.
@ -241,7 +243,7 @@ void main() {
await tags.create('unused');
await showList(tester);
await tester.tap(find.byTooltip('Filter'));
await tester.tap(find.byTooltip('Filter').hitTestable());
await tester.pumpAndSettle();
expect(chip<ChoiceChip>('unused'), findsNothing);
await tester.tap(chip<ChoiceChip>('verb'));
@ -252,7 +254,7 @@ void main() {
expect(find.text('食べる'), findsOneWidget);
expect(find.text('猫'), findsNothing);
await tester.tap(find.byTooltip('Filter'));
await tester.tap(find.byTooltip('Filter').hitTestable());
await tester.pumpAndSettle();
await tester.tap(find.text('Clear all'));
await tester.pump();