feat(#173): dégradés haut/bas sur la liste de bulles scrollable

Même logique que le slider enfants : fondu aux bords sauf au
premier et au dernier élément.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 17:10:41 +02:00
co-authored by Cursor
parent 07d3160bff
commit 2a24c0a061
+94 -15
View File
@@ -22,17 +22,32 @@ class CartesFeed extends StatefulWidget {
} }
class _CartesFeedState extends State<CartesFeed> { class _CartesFeedState extends State<CartesFeed> {
static const double _fadeExtent = 0.08;
List<CarteBulle> _items = const []; List<CarteBulle> _items = const [];
bool _loading = true; bool _loading = true;
String? _error; String? _error;
String? _busyCardId; String? _busyCardId;
final ScrollController _scrollController = ScrollController();
bool _isScrollable = false;
bool _fadeTop = false;
bool _fadeBottom = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_syncScrollFades);
_load(); _load();
} }
@override
void dispose() {
_scrollController.removeListener(_syncScrollFades);
_scrollController.dispose();
super.dispose();
}
@override @override
void didUpdateWidget(covariant CartesFeed oldWidget) { void didUpdateWidget(covariant CartesFeed oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -41,6 +56,25 @@ class _CartesFeedState extends State<CartesFeed> {
} }
} }
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<void> _load() async { Future<void> _load() async {
setState(() { setState(() {
_loading = true; _loading = true;
@@ -55,6 +89,7 @@ class _CartesFeedState extends State<CartesFeed> {
_items = items; _items = items;
_loading = false; _loading = false;
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _syncScrollFades());
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -304,22 +339,66 @@ class _CartesFeedState extends State<CartesFeed> {
); );
} }
return RefreshIndicator( return NotificationListener<ScrollMetricsNotification>(
onRefresh: _load, onNotification: (_) {
child: ListView.separated( _syncScrollFades();
padding: const EdgeInsets.only(bottom: 8), return false;
itemCount: _items.length, },
separatorBuilder: (_, __) => const SizedBox(height: 10), child: ShaderMask(
itemBuilder: (context, index) { blendMode: BlendMode.dstIn,
final card = _items[index]; shaderCallback: (Rect bounds) {
return _BulleTile( final stops = <double>[
card: card, 0.0,
busy: _busyCardId == card.id, _fadeExtent,
onAccept: () => _onAccept(card), 1.0 - _fadeExtent,
onAck: () => _onAck(card), 1.0,
onRefuse: () => _onRefuse(card), ];
); if (!_isScrollable) {
return const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
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: <Color>[
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),
);
},
),
),
), ),
); );
} }