fix(card): in the browser's detail view the list follows the card shown

Next and Previous changed the card beside the list, but the list stayed
where it was, so the card shown soon lay outside it. The list now
scrolls the card's tile into view: at its end going down, at its start
going up, and near it first where the tile lies too far to be built.

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 18:19:22 +02:00
commit 7a2f1a96d9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 121 additions and 11 deletions

View file

@ -1055,16 +1055,7 @@ class const _Detail({
children: [
SizedBox(
width: 280,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: cards.length,
separatorBuilder: (context, _) => const SizedBox(height: 8),
itemBuilder: (context, i) => _StackTile(
card: cards[i].card,
selected: i == index,
onTap: () => onCurrent(cards[i].card.id),
),
),
child: _DetailList(cards: cards, index: index, onCurrent: onCurrent),
),
const VerticalDivider(width: 1, thickness: 1),
Expanded(
@ -1116,6 +1107,90 @@ class const _Detail({
}
}
/// The list beside the card on the wide screen, following it: the card
/// shown, by the next or the one before, is scrolled into view.
class const _DetailList({
required final List<BrowsedCard> cards,
required final int index,
required final ValueChanged<String> onCurrent,
}) extends StatefulWidget {
@override
State<_DetailList> createState() => _DetailListState();
}
class _DetailListState() extends State<_DetailList> {
final _scroll = ScrollController();
/// The tile of the card shown.
final _shown = GlobalKey();
static const _duration = Duration(milliseconds: 200);
@override
void didUpdateWidget(_DetailList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.index != oldWidget.index) {
final down = widget.index > oldWidget.index;
WidgetsBinding.instance.addPostFrameCallback((_) => _follow(down: down));
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// Brings the card shown into view, at the list's end when going [down],
/// else at its start; where its tile isn't built, being far from what
/// shows, first near it by the tiles' average height.
void _follow({required bool down}) {
if (!mounted || !_scroll.hasClients) {
return;
}
if (_shown.currentContext case final tile?) {
Scrollable.ensureVisible(
tile,
duration: _duration,
curve: Curves.easeOut,
alignmentPolicy: down
? ScrollPositionAlignmentPolicy.keepVisibleAtEnd
: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
);
return;
}
final position = _scroll.position;
final perTile =
(position.maxScrollExtent + position.viewportDimension) /
widget.cards.length;
_scroll.jumpTo(
(widget.index * perTile - position.viewportDimension / 2).clamp(
0,
position.maxScrollExtent,
),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_shown.currentContext case final tile? when mounted) {
Scrollable.ensureVisible(tile, alignment: 0.5);
}
});
}
@override
Widget build(BuildContext context) => ListView.separated(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: widget.cards.length,
separatorBuilder: (context, _) => const SizedBox(height: 8),
itemBuilder: (context, i) => _StackTile(
key: i == widget.index ? _shown : null,
card: widget.cards[i].card,
selected: i == widget.index,
onTap: () => widget.onCurrent(widget.cards[i].card.id),
),
);
}
/// On a phone: the card, swiped to the next or the one before, above a
/// strip of all of them, the current one centred.
class const _Carousel({
@ -1223,6 +1298,7 @@ class const _StackTile({
required final Card card,
required final bool selected,
required final VoidCallback onTap,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {

View file

@ -260,6 +260,40 @@ void main() {
expect(find.text('Tokyo', findRichText: true), findsWidgets);
});
testWidgets('in the detail view the list follows the card shown, down '
'and back up', (tester) async {
for (var i = 1; i <= 40; i++) {
await cards.create(
'japanese',
CardContent(
front: RichContent.plain('Question $i'),
back: RichContent.plain('Answer $i'),
),
);
}
await show(tester);
await tester.tap(find.byTooltip('Detail'));
await tester.pumpAndSettle();
final list = tester.getRect(find.byType(ListView).first);
void expectInView(String front) {
final tile = tester.getRect(find.text(front).first);
expect(tile.top, greaterThanOrEqualTo(list.top));
expect(tile.bottom, lessThanOrEqualTo(list.bottom));
}
// From the first of the 40, made before the three of every test.
for (var i = 0; i < 38; i++) {
await tester.tap(find.byTooltip('Next'));
await tester.pumpAndSettle();
}
expectInView('Question 39');
for (var i = 0; i < 30; i++) {
await tester.tap(find.byTooltip('Previous'));
await tester.pumpAndSettle();
}
expectInView('Question 9');
});
testWidgets('on a phone, what can be done to the chosen cards sits at '
"the screen's foot, and one card shows above a strip of them", (
tester,