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:
parent
3181223940
commit
7a2f1a96d9
2 changed files with 121 additions and 11 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue