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:
parent
a461106dbf
commit
4ebfdca2fd
5 changed files with 262 additions and 46 deletions
|
|
@ -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,6 +146,7 @@ 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: EdgeFade(
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
|
@ -170,6 +172,7 @@ class _MonthStripState extends State<_MonthStrip> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
apps/app_seeds/lib/ui/edge_fade.dart
Normal file
82
apps/app_seeds/lib/ui/edge_fade.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(children: row),
|
child: Row(children: row),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +515,8 @@ class _MarketFilterBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return EdgeFade(
|
||||||
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|
@ -533,6 +535,7 @@ class _MarketFilterBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
apps/app_seeds/test/ui/edge_fade_test.dart
Normal file
125
apps/app_seeds/test/ui/edge_fade_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue