diff --git a/frontend/lib/widgets/quotidien/cartes_feed.dart b/frontend/lib/widgets/quotidien/cartes_feed.dart index 7d8b919..9c5e3fe 100644 --- a/frontend/lib/widgets/quotidien/cartes_feed.dart +++ b/frontend/lib/widgets/quotidien/cartes_feed.dart @@ -22,17 +22,32 @@ class CartesFeed extends StatefulWidget { } class _CartesFeedState extends State { + static const double _fadeExtent = 0.08; + List _items = const []; bool _loading = true; String? _error; String? _busyCardId; + final ScrollController _scrollController = ScrollController(); + bool _isScrollable = false; + bool _fadeTop = false; + bool _fadeBottom = false; + @override void initState() { super.initState(); + _scrollController.addListener(_syncScrollFades); _load(); } + @override + void dispose() { + _scrollController.removeListener(_syncScrollFades); + _scrollController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant CartesFeed oldWidget) { super.didUpdateWidget(oldWidget); @@ -41,6 +56,25 @@ class _CartesFeedState extends State { } } + void _syncScrollFades() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + final scrollable = position.maxScrollExtent > 0.0; + final threshold = position.viewportDimension * _fadeExtent / 2; + final fadeTop = scrollable && position.pixels > threshold; + final fadeBottom = scrollable && + position.pixels < (position.maxScrollExtent - threshold); + if (scrollable != _isScrollable || + fadeTop != _fadeTop || + fadeBottom != _fadeBottom) { + setState(() { + _isScrollable = scrollable; + _fadeTop = fadeTop; + _fadeBottom = fadeBottom; + }); + } + } + Future _load() async { setState(() { _loading = true; @@ -55,6 +89,7 @@ class _CartesFeedState extends State { _items = items; _loading = false; }); + WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades()); } catch (e) { if (!mounted) return; setState(() { @@ -304,22 +339,66 @@ class _CartesFeedState extends State { ); } - return RefreshIndicator( - onRefresh: _load, - child: ListView.separated( - padding: const EdgeInsets.only(bottom: 8), - itemCount: _items.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (context, index) { - final card = _items[index]; - return _BulleTile( - card: card, - busy: _busyCardId == card.id, - onAccept: () => _onAccept(card), - onAck: () => _onAck(card), - onRefuse: () => _onRefuse(card), - ); + return NotificationListener( + onNotification: (_) { + _syncScrollFades(); + return false; + }, + child: ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (Rect bounds) { + final stops = [ + 0.0, + _fadeExtent, + 1.0 - _fadeExtent, + 1.0, + ]; + if (!_isScrollable) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black, + Colors.black, + Colors.black, + Colors.black, + ], + stops: [0.0, 0.08, 0.92, 1.0], + ).createShader(bounds); + } + final topMask = _fadeTop ? Colors.transparent : Colors.black; + final bottomMask = _fadeBottom ? Colors.transparent : Colors.black; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + topMask, + Colors.black, + Colors.black, + bottomMask, + ], + stops: stops, + ).createShader(bounds); }, + child: RefreshIndicator( + onRefresh: _load, + child: ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.only(top: 4, bottom: 8), + itemCount: _items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final card = _items[index]; + return _BulleTile( + card: card, + busy: _busyCardId == card.id, + onAccept: () => _onAccept(card), + onAck: () => _onAck(card), + onRefuse: () => _onRefuse(card), + ); + }, + ), + ), ), ); }