diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart index 29c7c9d..7693e3d 100644 --- a/apps/app_seeds/lib/ui/calendar_screen.dart +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -8,6 +8,7 @@ import '../data/variety_repository.dart'; import '../domain/crop_calendar.dart'; import '../i18n/strings.g.dart'; import 'category_palette.dart'; +import 'edge_fade.dart'; import 'theme.dart'; /// 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()); return SizedBox( height: 56, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - itemCount: 12, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final m = i + 1; - final selected = m == widget.month; - return ChoiceChip( - key: Key('calendar.month.$m'), - labelPadding: const EdgeInsets.symmetric(horizontal: 6), - label: Text( - _capitalise(fmt.format(DateTime(2000, m))), - key: selected ? _selectedKey : null, - ), - selected: selected, - onSelected: (_) => widget.onSelect(m), - selectedColor: seedPrimaryContainer, - labelStyle: TextStyle( - color: selected ? seedOnPrimaryContainer : seedMuted, - fontWeight: selected ? FontWeight.w600 : FontWeight.w400, - ), - ); - }, + child: EdgeFade( + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + itemCount: 12, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) { + final m = i + 1; + final selected = m == widget.month; + return ChoiceChip( + key: Key('calendar.month.$m'), + labelPadding: const EdgeInsets.symmetric(horizontal: 6), + label: Text( + _capitalise(fmt.format(DateTime(2000, m))), + key: selected ? _selectedKey : null, + ), + selected: selected, + onSelected: (_) => widget.onSelect(m), + selectedColor: seedPrimaryContainer, + labelStyle: TextStyle( + color: selected ? seedOnPrimaryContainer : seedMuted, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ); + }, + ), ), ); } diff --git a/apps/app_seeds/lib/ui/edge_fade.dart b/apps/app_seeds/lib/ui/edge_fade.dart new file mode 100644 index 0000000..e498021 --- /dev/null +++ b/apps/app_seeds/lib/ui/edge_fade.dart @@ -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 createState() => EdgeFadeState(); +} + +class EdgeFadeState extends State { + 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( + onNotification: (n) => n.depth == 0 ? _onMetrics(n.metrics) : false, + child: NotificationListener( + 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, + ), + ), + ); + } +} diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 4a49461..bbadda3 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -11,6 +11,7 @@ import '../services/share_catalog_service.dart'; import '../state/inventory_cubit.dart'; import 'category_palette.dart'; import 'draft_triage.dart'; +import 'edge_fade.dart'; import 'label_print_sheet.dart'; import 'quantity_kind_l10n.dart'; import 'quantity_picker.dart'; @@ -401,10 +402,12 @@ class _FilterBar extends StatelessWidget { )); } - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row(children: row), + return EdgeFade( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row(children: row), + ), ); } } diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index fa1a18b..0b05fcc 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -12,6 +12,7 @@ import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.dart'; +import 'edge_fade.dart'; import 'market_widgets.dart'; import 'theme.dart'; @@ -514,24 +515,26 @@ class _MarketFilterBar extends StatelessWidget { ), ]; - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - children: [ - for (final chip in chips) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: chip, - ), - if (state.hasActiveFilter) - TextButton.icon( - key: const Key('market.filter.clear'), - onPressed: cubit.clearFilters, - icon: const Icon(Icons.clear, size: 18), - label: Text(t.inventory.clearFilters), - ), - ], + return EdgeFade( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + for (final chip in chips) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: chip, + ), + if (state.hasActiveFilter) + TextButton.icon( + key: const Key('market.filter.clear'), + onPressed: cubit.clearFilters, + icon: const Icon(Icons.clear, size: 18), + label: Text(t.inventory.clearFilters), + ), + ], + ), ), ); } diff --git a/apps/app_seeds/test/ui/edge_fade_test.dart b/apps/app_seeds/test/ui/edge_fade_test.dart new file mode 100644 index 0000000..e60d7c6 --- /dev/null +++ b/apps/app_seeds/test/ui/edge_fade_test.dart @@ -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(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); + }); +}