feat(ux): edge fades hint that filter rows scroll horizontally

Chips clipped at the viewport edge with no cue that more filters exist.
New reusable EdgeFade widget (ShaderMask + dstIn) fades an edge only
while content remains beyond it, driven by scroll metrics; RTL mirrors
via Directionality. Applied to the inventory filter bar, market filter
bar and calendar month strip.
This commit is contained in:
vjrj 2026-07-11 07:14:19 +02:00
parent a461106dbf
commit 4ebfdca2fd
5 changed files with 262 additions and 46 deletions

View file

@ -0,0 +1,125 @@
import 'package:tane/ui/edge_fade.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// A horizontal strip of [count] fixed-width boxes inside an EdgeFade-wrapped
// SingleChildScrollView, laid out at the default 800px test surface width.
Widget harness({
required int count,
TextDirection direction = TextDirection.ltr,
ScrollController? controller,
}) {
return Directionality(
textDirection: direction,
child: EdgeFade(
child: SingleChildScrollView(
controller: controller,
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var i = 0; i < count; i++)
const SizedBox(width: 100, height: 40),
],
),
),
),
);
}
EdgeFadeState state(WidgetTester tester) =>
tester.state<EdgeFadeState>(find.byType(EdgeFade));
testWidgets('no fade on either edge when content fits', (tester) async {
await tester.pumpWidget(harness(count: 3)); // 300px < 800px
await tester.pump(); // let the post-layout metrics notification land
expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isFalse);
});
testWidgets('LTR: at start only the trailing (right) edge fades',
(tester) async {
await tester.pumpWidget(harness(count: 20)); // 2000px > 800px
await tester.pump();
expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isTrue);
});
testWidgets('LTR: in the middle both edges fade', (tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(harness(count: 20, controller: controller));
await tester.pump();
controller.jumpTo(600);
await tester.pump();
expect(state(tester).fadeLeft, isTrue);
expect(state(tester).fadeRight, isTrue);
});
testWidgets('LTR: at the end only the leading (left) edge fades',
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(harness(count: 20, controller: controller));
await tester.pump();
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(state(tester).fadeLeft, isTrue);
expect(state(tester).fadeRight, isFalse);
});
testWidgets('RTL: at start only the LEFT edge fades (mirrored)',
(tester) async {
await tester
.pumpWidget(harness(count: 20, direction: TextDirection.rtl));
await tester.pump();
expect(state(tester).fadeLeft, isTrue);
expect(state(tester).fadeRight, isFalse);
});
testWidgets('RTL: at the end only the RIGHT edge fades (mirrored)',
(tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(harness(
count: 20,
direction: TextDirection.rtl,
controller: controller,
));
await tester.pump();
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isTrue);
});
testWidgets('RTL: no fade when content fits', (tester) async {
await tester.pumpWidget(harness(count: 3, direction: TextDirection.rtl));
await tester.pump();
expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isFalse);
});
testWidgets('fades clear when content shrinks to fit', (tester) async {
await tester.pumpWidget(harness(count: 20));
await tester.pump();
expect(state(tester).fadeRight, isTrue);
await tester.pumpWidget(harness(count: 3));
await tester.pump();
expect(state(tester).fadeLeft, isFalse);
expect(state(tester).fadeRight, isFalse);
});
testWidgets('scrolling by drag updates the fades', (tester) async {
await tester.pumpWidget(harness(count: 20));
await tester.pump();
expect(state(tester).fadeLeft, isFalse);
await tester.drag(
find.byType(SingleChildScrollView), const Offset(-300, 0));
await tester.pump();
expect(state(tester).fadeLeft, isTrue);
expect(state(tester).fadeRight, isTrue);
});
}