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 1ab243f29e
commit bfff95fe8d
5 changed files with 262 additions and 46 deletions

View file

@ -8,6 +8,7 @@ import '../data/variety_repository.dart';
import '../domain/crop_calendar.dart'; import '../domain/crop_calendar.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'category_palette.dart'; import 'category_palette.dart';
import 'edge_fade.dart';
import 'theme.dart'; import 'theme.dart';
/// One crop-calendar phase as shown on the "this month" screen: its label, /// One crop-calendar phase as shown on the "this month" screen: its label,
@ -145,30 +146,32 @@ class _MonthStripState extends State<_MonthStrip> {
final fmt = DateFormat.MMM(locale.toLanguageTag()); final fmt = DateFormat.MMM(locale.toLanguageTag());
return SizedBox( return SizedBox(
height: 56, height: 56,
child: ListView.separated( child: EdgeFade(
scrollDirection: Axis.horizontal, child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), scrollDirection: Axis.horizontal,
itemCount: 12, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
separatorBuilder: (_, _) => const SizedBox(width: 8), itemCount: 12,
itemBuilder: (context, i) { separatorBuilder: (_, _) => const SizedBox(width: 8),
final m = i + 1; itemBuilder: (context, i) {
final selected = m == widget.month; final m = i + 1;
return ChoiceChip( final selected = m == widget.month;
key: Key('calendar.month.$m'), return ChoiceChip(
labelPadding: const EdgeInsets.symmetric(horizontal: 6), key: Key('calendar.month.$m'),
label: Text( labelPadding: const EdgeInsets.symmetric(horizontal: 6),
_capitalise(fmt.format(DateTime(2000, m))), label: Text(
key: selected ? _selectedKey : null, _capitalise(fmt.format(DateTime(2000, m))),
), key: selected ? _selectedKey : null,
selected: selected, ),
onSelected: (_) => widget.onSelect(m), selected: selected,
selectedColor: seedPrimaryContainer, onSelected: (_) => widget.onSelect(m),
labelStyle: TextStyle( selectedColor: seedPrimaryContainer,
color: selected ? seedOnPrimaryContainer : seedMuted, labelStyle: TextStyle(
fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: selected ? seedOnPrimaryContainer : seedMuted,
), fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
); ),
}, );
},
),
), ),
); );
} }

View file

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
/// Fades the edges of a horizontal scrollable to transparency wherever more
/// content lies beyond that edge, so it is obvious the row scrolls.
///
/// Wrap directly around a horizontal [SingleChildScrollView] or [ListView].
/// The fade is a [ShaderMask] with [BlendMode.dstIn], so it melts into
/// whatever background sits behind no theme colour involved. Edge state is
/// tracked per logical side (start/end) and mapped to physical left/right via
/// [Directionality], so RTL mirrors correctly.
class EdgeFade extends StatefulWidget {
const EdgeFade({super.key, required this.child, this.fadeWidth = 28});
/// The horizontal scrollable to mask.
final Widget child;
/// Width of each faded edge in logical pixels.
final double fadeWidth;
@override
State<EdgeFade> createState() => EdgeFadeState();
}
class EdgeFadeState extends State<EdgeFade> {
bool _fadeStart = false;
bool _fadeEnd = false;
/// Whether the physical left edge is currently faded.
@visibleForTesting
bool get fadeLeft => _isRtl ? _fadeEnd : _fadeStart;
/// Whether the physical right edge is currently faded.
@visibleForTesting
bool get fadeRight => _isRtl ? _fadeStart : _fadeEnd;
bool get _isRtl => Directionality.of(context) == TextDirection.rtl;
bool _onMetrics(ScrollMetrics metrics) {
if (metrics.axis != Axis.horizontal) return false;
final start = metrics.extentBefore > 0.5;
final end = metrics.extentAfter > 0.5;
if (start != _fadeStart || end != _fadeEnd) {
setState(() {
_fadeStart = start;
_fadeEnd = end;
});
}
return false;
}
@override
Widget build(BuildContext context) {
final fadeLeft = this.fadeLeft;
final fadeRight = this.fadeRight;
// The mask stays in the tree even when both edges are opaque: swapping it
// in and out would re-inflate the child and reset its scroll position.
return NotificationListener<ScrollMetricsNotification>(
onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false,
child: NotificationListener<ScrollNotification>(
onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false,
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (bounds) {
final w = bounds.width > 0
? (widget.fadeWidth / bounds.width).clamp(0.0, 0.5)
: 0.0;
return LinearGradient(
colors: [
fadeLeft ? Colors.transparent : Colors.white,
Colors.white,
Colors.white,
fadeRight ? Colors.transparent : Colors.white,
],
stops: [0, w, 1 - w, 1],
).createShader(bounds);
},
child: widget.child,
),
),
);
}
}

View file

@ -11,6 +11,7 @@ import '../services/share_catalog_service.dart';
import '../state/inventory_cubit.dart'; import '../state/inventory_cubit.dart';
import 'category_palette.dart'; import 'category_palette.dart';
import 'draft_triage.dart'; import 'draft_triage.dart';
import 'edge_fade.dart';
import 'label_print_sheet.dart'; import 'label_print_sheet.dart';
import 'quantity_kind_l10n.dart'; import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart'; import 'quantity_picker.dart';
@ -401,10 +402,12 @@ class _FilterBar extends StatelessWidget {
)); ));
} }
return SingleChildScrollView( return EdgeFade(
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12), scrollDirection: Axis.horizontal,
child: Row(children: row), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: row),
),
); );
} }
} }

View file

@ -12,6 +12,7 @@ import '../services/social_connection.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import '../state/offers_cubit.dart'; import '../state/offers_cubit.dart';
import 'edge_fade.dart';
import 'market_widgets.dart'; import 'market_widgets.dart';
import 'theme.dart'; import 'theme.dart';
@ -514,24 +515,26 @@ class _MarketFilterBar extends StatelessWidget {
), ),
]; ];
return SingleChildScrollView( return EdgeFade(
scrollDirection: Axis.horizontal, child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), scrollDirection: Axis.horizontal,
child: Row( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
children: [ child: Row(
for (final chip in chips) children: [
Padding( for (final chip in chips)
padding: const EdgeInsetsDirectional.only(end: 8), Padding(
child: chip, padding: const EdgeInsetsDirectional.only(end: 8),
), child: chip,
if (state.hasActiveFilter) ),
TextButton.icon( if (state.hasActiveFilter)
key: const Key('market.filter.clear'), TextButton.icon(
onPressed: cubit.clearFilters, key: const Key('market.filter.clear'),
icon: const Icon(Icons.clear, size: 18), onPressed: cubit.clearFilters,
label: Text(t.inventory.clearFilters), icon: const Icon(Icons.clear, size: 18),
), label: Text(t.inventory.clearFilters),
], ),
],
),
), ),
); );
} }

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);
});
}