scrolled_pagination 1.9.0
scrolled_pagination: ^1.9.0 copied to clipboard
A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, eight builders, hasReachedEnd, item + batch ops, key-b [...]
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:scrolled_pagination/scrolled_pagination.dart';
import 'chrome.dart';
import 'code_reference.dart';
import 'data.dart';
import 'frames.dart';
import 'theme.dart';
import 'widgets.dart';
void main() => runApp(const DemoApp());
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'scrolled_pagination',
debugShowCheckedModeBanner: false,
theme: CA.theme(),
home: const Workbench(),
);
}
}
class Workbench extends StatefulWidget {
const Workbench({super.key});
@override
State<Workbench> createState() => _WorkbenchState();
}
class _WorkbenchState extends State<Workbench> {
String dataset = 'feed';
String mode = 'page';
String kind = 'future';
String layout = 'list';
String sortMode = 'none';
bool emptyOn = false;
bool reachEndOn = true;
bool fabsOn = true;
String lastEvent = '—';
int _visFirst = -1;
int _visLast = -1;
SortManager<Object>? _sort;
// Stable GlobalKeys so the preview views keep their scroll position + state
// even when they are reparented across breakpoints while the window resizes.
final GlobalKey _phoneViewKey = GlobalKey();
final GlobalKey _browserViewKey = GlobalKey();
late PaginationController<Object> _controller = _make();
int _keyOf(Object o) => o is Post ? o.id : (o as Member).id;
PaginationController<Object> _make() {
_sort = switch (sortMode) {
'asc' => SortManager<Object>.byIndex(_keyOf, enabled: true, order: SortOrder.ascending),
'desc' => SortManager<Object>.byIndex(_keyOf, enabled: true, order: SortOrder.descending),
_ => null,
};
return PaginationController<Object>(
pageSize: 12,
initialPageParam: mode == 'cursor' ? null : 1,
getKey: _keyOf,
openFocusManager: true,
sortManager: _sort,
provider: makeProvider(dataset: dataset, mode: mode, kind: kind),
// Reach-end is inferred from the page's hasMore flag / a short page.
// To override, pass: detectReachEnd: (page, items) => page.items.isEmpty
onItemInserted: (i, _) => _log('onItemInserted #$i'),
onItemUpdated: (i, _) => _log('onItemUpdated #$i'),
onItemDeleted: (i, _) => _log('onItemDeleted #$i'),
onItemReplaced: (i, _) => _log('onItemReplaced #$i'),
onItemMoved: (a, b) => _log('onItemMoved $a→$b'),
onFocusChanged: (i, _) => _log('onFocusChanged #$i'),
onJumbFailed: (f) => _log('onJumbFailed: ${f.reason}'),
onIndexOutOfRange: (i) => _log('onIndexOutOfRange #$i'),
// Live visible-item range, observed by the view (scrollview_observer).
// Fires without rebuilding the list — cheap to react to.
onVisibleItemsChanged: (first, last, _) {
if (!mounted) return;
setState(() { _visFirst = first; _visLast = last; });
},
);
}
void _log(String e) => setState(() => lastEvent = e);
void _reconfigure() {
final old = _controller;
_controller = _make();
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) => old.dispose());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Object? get _first => _controller.items.isEmpty ? null : _controller.items.first;
Object _synthetic({String? title}) {
final id = DateTime.now().microsecondsSinceEpoch;
if (dataset == 'members') {
return Member(id: id, name: 'New Member', role: 'Member', club: 'Madina IT', online: true, tasks: 0, since: 2026);
}
return Post(
id: id,
kind: 'news',
club: 'Madina IT',
author: 'You',
when: 'Just now',
title: title ?? 'New post added via controller',
excerpt: 'This item was inserted without rebuilding the rest of the list.',
likes: 0,
comments: 0,
);
}
PaginationLayout get _layout => switch (layout) {
'grid' => PaginationLayout.grid,
'separated' => PaginationLayout.separated,
_ => PaginationLayout.list,
};
bool get _reverse => layout == 'reverse';
bool get _grid => layout == 'grid';
// --- item builders ----------------------------------------------------------
Widget _phoneItem(BuildContext context, Object item, int index) {
if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
final p = item as Post;
return _grid ? FeedCardV(post: p) : FeedCardH(post: p);
}
Widget _browserItem(BuildContext context, Object item, int index) {
if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
final p = item as Post;
return _grid ? FeedCardV(post: p) : FeedRowWide(post: p);
}
// --- shared builder slots ---------------------------------------------------
Widget _firstLoading(BuildContext _) => SkeletonList(grid: _grid);
Widget _empty(BuildContext _, Future<void> Function() refresh) => _Empty(onRefresh: refresh, dataset: dataset);
Widget _error(BuildContext _, Object error, Future<void> Function() retry) => _Error(error: error, onRetry: retry);
PaginatedScrollView<Object> _view({
required Key key,
required PaginatedItemBuilder<Object> itemBuilder,
required EdgeInsets padding,
required double gap,
required int crossAxisCount,
required double childAspectRatio,
required bool pullToRefresh,
}) {
return PaginatedScrollView<Object>(
key: key,
controller: _controller,
layout: _layout,
reverse: _reverse,
loadMoreThreshold: 280,
padding: padding,
gap: gap,
crossAxisCount: crossAxisCount,
childAspectRatio: childAspectRatio,
pullToRefresh: pullToRefresh,
itemBuilder: itemBuilder,
separatorBuilder: (_, __) => const Divider(height: 20, color: CA.ink100),
firstPageLoadingBuilder: _firstLoading,
loadMoreBuilder: (_) => const _LoadMore(),
// reachEndBuilder (optional) wins over noMoreBuilder when toggled on;
// pass null and nothing renders at the end.
reachEndBuilder: reachEndOn ? (_) => const _ReachEnd() : null,
noMoreBuilder: (_) => const _NoMore(),
emptyBuilder: _empty,
errorBuilder: _error,
// Optional scroll-navigation FABs — independent of the item list.
// Auto-hide at each edge; tap to animate to the first / last item.
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
scrollToFirstFloatingButton: fabsOn
? const ScrollEdgeFab(
type: ScrollFabType.small,
tooltip: 'Back to top',
icon: Icon(Icons.arrow_upward_rounded),
backgroundColor: CA.violet,
foregroundColor: Colors.white,
)
: null,
scrollToLastFloatingButton: fabsOn
? const ScrollEdgeFab(
type: ScrollFabType.small,
tooltip: 'Jump to end',
icon: Icon(Icons.arrow_downward_rounded),
backgroundColor: CA.violet,
foregroundColor: Colors.white,
)
: null,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: CA.canvas,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final size = screenSizeOf(constraints.maxWidth);
final gutter = size == ScreenSize.mobile ? 16.0 : 24.0;
return SingleChildScrollView(
padding: EdgeInsets.fromLTRB(gutter, 18, gutter, 32),
child: _body(size),
);
},
),
),
);
}
// Mobile: single column (collapsible controls above the preview).
// Tablet / desktop: two columns (a controls rail + the preview).
Widget _body(ScreenSize size) {
final frameH = (MediaQuery.of(context).size.height * 0.72).clamp(440.0, 760.0);
final preview = _preview(size, frameH);
if (size == ScreenSize.mobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(size),
const SizedBox(height: 16),
_controlsPanel(size),
const SizedBox(height: 16),
preview,
const SizedBox(height: 16),
const StatesLegend(),
],
);
}
final railWidth = size == ScreenSize.desktop ? 340.0 : 300.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(size),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: railWidth, child: _controlsPanel(size)),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
preview,
const SizedBox(height: 16),
const StatesLegend(),
],
),
),
],
),
],
);
}
// --- header -----------------------------------------------------------------
Widget _header(ScreenSize size) {
final brand = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(13),
gradient: const LinearGradient(
colors: [CA.violet, CA.violet700],
begin: Alignment.topLeft,
end: Alignment.bottomRight),
),
alignment: Alignment.center,
child: const Text('SP', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 18)),
),
const SizedBox(width: 14),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Scrolled Pagination',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: CA.ink900)),
SizedBox(height: 2),
Text('One controller · many providers · eight builders — a reusable infinite-scroll component for ClubApp',
style: TextStyle(fontSize: 13, color: CA.ink500)),
],
),
),
],
);
final status = StatusPill(
controller: _controller,
lastEvent: lastEvent,
visibleRange: _visFirst < 0 ? null : '$_visFirst–$_visLast',
);
if (size == ScreenSize.desktop) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [Expanded(child: brand), const SizedBox(width: 24), Flexible(child: status)],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [brand, const SizedBox(height: 14), status],
);
}
// --- controls: configuration + grouped, collapsible action sections ---------
Widget _controlsPanel(ScreenSize size) {
Widget gap() => const SizedBox(height: 12);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// primary actions — always visible
Container(
decoration: BoxDecoration(
color: CA.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: CA.cardShadow,
),
padding: const EdgeInsets.all(12),
child: ActionGrid(children: [
ActionButton('Refresh', tone: ActTone.accent, icon: Icons.refresh, onTap: _controller.refresh),
ActionButton('Load more', onTap: _controller.loadMore),
ActionButton('+ Prepend', onTap: () => _controller.addItem(_synthetic(), prepend: true)),
ActionButton('Clear', onTap: _controller.clear),
]),
),
gap(),
CollapsibleCard(
title: 'Configuration',
initiallyOpen: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Segmented<String>(
label: 'Data source',
value: dataset,
options: const [(value: 'feed', label: 'Feed posts'), (value: 'members', label: 'Members')],
onChanged: (v) { dataset = v; _reconfigure(); },
),
const SizedBox(height: 14),
Segmented<String>(
label: 'Pagination',
value: mode,
options: const [(value: 'page', label: 'Page-based'), (value: 'cursor', label: 'Cursor-based')],
onChanged: (v) { mode = v; _reconfigure(); },
),
const SizedBox(height: 14),
Segmented<String>(
label: 'Provider',
value: kind,
options: const [(value: 'future', label: 'Future'), (value: 'stream', label: 'Stream'), (value: 'sync', label: 'Sync')],
onChanged: (v) { kind = v; _reconfigure(); },
),
const SizedBox(height: 14),
Segmented<String>(
label: 'Layout',
value: layout,
options: const [(value: 'list', label: 'List'), (value: 'grid', label: 'Grid'), (value: 'separated', label: 'Separated'), (value: 'reverse', label: 'Reverse')],
onChanged: (v) => setState(() => layout = v),
),
const SizedBox(height: 14),
Segmented<String>(
label: 'Sort',
value: sortMode,
options: const [(value: 'none', label: 'Insertion'), (value: 'asc', label: 'Id ↑'), (value: 'desc', label: 'Id ↓')],
onChanged: (v) { sortMode = v; _reconfigure(); },
),
],
),
),
gap(),
CollapsibleCard(title: 'Add & insert', child: ActionGrid(children: [
ActionButton('+ Append', onTap: () => _controller.addItem(_synthetic())),
ActionButton('insertItem @2', onTap: () => _controller.insertItem(2, _synthetic())),
ActionButton('insertItems ×3 @0', onTap: () => _controller.insertItems(0, [_synthetic(), _synthetic(), _synthetic()])),
])),
gap(),
CollapsibleCard(title: 'Update', child: ActionGrid(children: [
ActionButton('updateItem first', onTap: _updateFirst),
ActionButton('updateWhere', onTap: _updateWhere),
ActionButton('updateFirstWhere', onTap: _updateFirstWhere),
ActionButton('updateAll', onTap: _updateAll),
])),
gap(),
CollapsibleCard(title: 'Replace', child: ActionGrid(children: [
ActionButton('replaceAt @1', onTap: () {
if (_controller.count > 1) _controller.replaceAt(1, _synthetic(title: 'Replaced via replaceAt(1)'));
}),
ActionButton('replaceFirstWhere', onTap: _replaceFirstWhere),
ActionButton('replaceWhere', onTap: _replaceWhere),
ActionButton('replaceAll (shuffle)', onTap: () {
final next = List<Object>.of(_controller.items)..shuffle();
_controller.replaceAll(next);
}),
])),
gap(),
CollapsibleCard(title: 'Remove', child: ActionGrid(children: [
ActionButton('– Remove first', tone: ActTone.danger, onTap: () {
final f = _first;
if (f != null) _controller.removeItem(_keyOf(f));
}),
ActionButton('deleteWhere', tone: ActTone.danger, onTap: _deleteWhere),
ActionButton('deleteFirstWhere', tone: ActTone.danger, onTap: _deleteFirstWhere),
])),
gap(),
CollapsibleCard(title: 'Reorder', child: ActionGrid(children: [
ActionButton('moveItem 0→5', onTap: () => _controller.moveItem(0, 5)),
ActionButton('moveToFirst 4', onTap: () => _controller.moveToFirst(4)),
ActionButton('moveToLast 0', onTap: () => _controller.moveToLast(0)),
ActionButton('swapItems 0↔3', onTap: () => _controller.swapItems(0, 3)),
])),
gap(),
CollapsibleCard(title: 'Batch', hint: 'one notify each', child: ActionGrid(children: [
ActionButton('insertItemsBatch', onTap: () =>
_controller.insertItemsBatch([BatchInsert<Object>(0, _synthetic()), BatchInsert<Object>(5, _synthetic())])),
ActionButton('replaceItemsBatch', onTap: _replaceBatch),
ActionButton('deleteItemsBatch', tone: ActTone.danger, onTap: _deleteBatch),
ActionButton('moveItemsBatch', onTap: () => _controller.moveItemsBatch(const [BatchMove(0, 5), BatchMove(2, 0)])),
ActionButton('transaction', tone: ActTone.accent, onTap: _txDemo),
])),
gap(),
CollapsibleCard(title: 'Sort', child: ActionGrid(children: [
ActionButton('Flip order + resort', icon: Icons.swap_vert, onTap: _flipSort),
])),
gap(),
CollapsibleCard(title: 'Select', child: ActionGrid(children: [
ActionButton('selectAll', onTap: _controller.selectAll),
ActionButton('toggle first 3', onTap: () {
for (final o in _controller.items.take(3)) {
_controller.toggleSelected(_keyOf(o));
}
_log('selected ${_controller.selectedKeys.length}');
}),
ActionButton('clearSelection', onTap: _controller.clearSelection),
])),
gap(),
CollapsibleCard(title: 'Scroll', child: ActionGrid(children: [
ActionButton('jumbToFirst', onTap: () => _controller.jumbToFirst()),
ActionButton('jumbToLast', onTap: () => _controller.jumbToLast()),
ActionButton('jumbTo 20', onTap: () => _controller.jumbTo(20)),
ActionButton('jumbWhere last', onTap: _jumbWhereLast),
ActionButton('animateToFirst', onTap: () => _controller.animateToFirst()),
ActionButton('animateToLast', onTap: () => _controller.animateToLast()),
ActionButton('animateTo 12', tone: ActTone.accent, onTap: () => _controller.animateTo(12, options: const ScrollOptions(alignment: 0.5))),
ActionButton('moveTo 30', onTap: () => _controller.moveTo(30)),
ActionButton('ensureVisibleAt 8', onTap: () => _controller.ensureVisibleAt(8, options: const ScrollOptions(alignment: 0.5))),
ActionButton('ensureVisibleWhere', onTap: _ensureVisibleWhere),
])),
gap(),
CollapsibleCard(title: 'Focus', child: ActionGrid(children: [
ActionButton('focusFirst', onTap: () => _controller.focusFirst()),
ActionButton('focusLast', onTap: () => _controller.focusLast()),
ActionButton('focusAt 3', onTap: () => _controller.focusAt(3)),
ActionButton('focusWhere', onTap: _focusWhere),
ActionButton('‹ focusPrev', onTap: () => _controller.focusPrevious()),
ActionButton('focusNext ›', tone: ActTone.accent, onTap: () => _controller.focusNext()),
ActionButton('clearFocus', onTap: _controller.clearFocus),
])),
gap(),
CollapsibleCard(title: 'States & simulation', child: ActionGrid(children: [
ActionButton('Retry', onTap: _controller.retry),
ActionButton('Force error', tone: ActTone.danger, onTap: () {
Sim.failRate = 1;
_controller.refresh().whenComplete(() => Sim.failRate = 0);
}),
ActionButton(emptyOn ? 'Empty: on' : 'Empty state', tone: emptyOn ? ActTone.danger : ActTone.normal, onTap: () {
setState(() => emptyOn = !emptyOn);
Sim.emptyMode = emptyOn;
_controller.refresh();
}),
ActionButton(reachEndOn ? 'reachEndBuilder: on' : 'reachEndBuilder: off',
tone: reachEndOn ? ActTone.accent : ActTone.normal,
onTap: () => setState(() => reachEndOn = !reachEndOn)),
ActionButton(fabsOn ? 'Scroll FABs: on' : 'Scroll FABs: off',
tone: fabsOn ? ActTone.accent : ActTone.normal,
onTap: () => setState(() => fabsOn = !fabsOn)),
])),
],
);
}
// --- action rows ------------------------------------------------------------
Object? _at(int i) => (i >= 0 && i < _controller.count) ? _controller.items[i] : null;
// --- preview: phone + browser frames + code reference -----------------------
Widget _preview(ScreenSize size, double frameH) {
final feed = dataset != 'members';
final phoneTitle = feed ? 'News & articles' : 'Members';
final phoneSub = '${mode == 'cursor' ? 'cursor-based' : 'page-based'} · $kind';
final phoneView = _view(
key: _phoneViewKey,
itemBuilder: _phoneItem,
padding: const EdgeInsets.all(14),
gap: 12,
crossAxisCount: 2,
childAspectRatio: feed ? 0.74 : 0.85,
pullToRefresh: true,
);
final browserView = _view(
key: _browserViewKey,
itemBuilder: _browserItem,
padding: feed && size != ScreenSize.mobile
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.all(16),
gap: feed ? 0 : 12,
crossAxisCount: size == ScreenSize.desktop ? 3 : 2,
childAspectRatio: feed ? 0.9 : 1.0,
// pull-to-refresh on touch surfaces (mobile/tablet); off on desktop mouse
pullToRefresh: size != ScreenSize.desktop,
);
final codeH = (MediaQuery.of(context).size.height * 0.3).clamp(200.0, 300.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PreviewArea(
size: size,
title: phoneTitle,
subtitle: phoneSub,
url: 'app.clubapp.com / ${feed ? 'feed' : 'members'}',
frameHeight: frameH,
phoneChild: phoneView,
browserChild: browserView,
),
const SizedBox(height: 18),
CodeReferencePanel(height: codeH),
],
);
}
void _updateFirst() {
final f = _first;
if (f is Post) {
_controller.updateItem(f.id, (o) {
final p = o as Post;
return p.copyWith(liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1);
});
} else if (f is Member) {
_controller.updateItem(f.id, (o) {
final m = o as Member;
return m.copyWith(online: !m.online);
});
}
}
void _updateFirstWhere() {
if (dataset == 'members') {
_controller.updateFirstWhere(
(o) => o is Member,
(o) {
final m = o as Member;
return m.copyWith(online: !m.online);
},
);
} else {
_controller.updateFirstWhere(
(o) => o is Post,
(o) {
final p = o as Post;
return p.copyWith(liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1);
},
);
}
}
void _updateAll() {
if (dataset == 'members') {
_controller.updateAll((o) => (o as Member).copyWith(online: true));
} else {
_controller.updateAll((o) {
final p = o as Post;
return p.copyWith(liked: true);
});
}
}
void _updateWhere() {
if (dataset == 'members') {
_controller.updateWhere(
(o) => o is Member && o.role == 'Member',
(o) {
final m = o as Member;
return m.copyWith(role: 'Moderator');
},
);
} else {
_controller.updateWhere(
(o) => o is Post && o.club == 'Madina IT',
(o) {
final p = o as Post;
return p.copyWith(liked: true, likes: p.likes + 1);
},
);
}
}
void _replaceFirstWhere() {
final f = _first;
if (f == null) return;
_controller.replaceFirstWhere(
(o) => _keyOf(o) == _keyOf(f),
_synthetic(title: 'Replaced via replaceFirstWhere'),
);
}
void _replaceWhere() {
if (dataset == 'members') {
_controller.replaceWhere(
(o) => o is Member && o.role == 'Guest',
(o) => (o as Member).copyWith(role: 'Member'),
);
} else {
_controller.replaceWhere(
(o) => o is Post && o.kind == 'article',
(o) => (o as Post).copyWith(title: 'Re-published article'),
);
}
}
void _deleteWhere() {
if (dataset == 'members') {
_controller.deleteWhere((o) => o is Member && !o.online);
} else {
_controller.deleteWhere((o) => o is Post && o.kind == 'article');
}
}
void _deleteFirstWhere() {
if (dataset == 'members') {
_controller.deleteFirstWhere((o) => o is Member && !o.online);
} else {
_controller.deleteFirstWhere((o) => o is Post && o.kind == 'article');
}
}
void _replaceBatch() {
final ks = _controller.items.take(2).map(_keyOf).toList();
if (ks.length < 2) return;
_controller.replaceItemsBatch([
BatchReplace<Object>(ks[0], _synthetic(title: 'Batch-replaced #1')),
BatchReplace<Object>(ks[1], _synthetic(title: 'Batch-replaced #2')),
]);
}
void _deleteBatch() {
final ks = _controller.items.take(3).map(_keyOf).toList();
if (ks.isNotEmpty) _controller.deleteItemsBatch(ks);
}
void _txDemo() {
_controller.transaction(() {
_controller.addItem(_synthetic(title: 'Added in a transaction'), prepend: true);
final fifth = _at(4);
if (fifth != null) _controller.removeItem(_keyOf(fifth));
_controller.moveItem(0, 2);
});
_log('transaction → one notify');
}
void _flipSort() {
final s = _sort;
if (s == null) {
_log('enable Sort first');
return;
}
s.order = s.order == SortOrder.ascending ? SortOrder.descending : SortOrder.ascending;
_controller.resort();
_log('resort → ${s.order.name}');
}
void _jumbWhereLast() {
if (_controller.items.isEmpty) return;
final last = _controller.items.last;
_controller.jumbWhere((o) => _keyOf(o) == _keyOf(last));
}
void _ensureVisibleWhere() {
final target = _at(10);
if (target == null) return;
_controller.ensureVisibleWhere((o) => _keyOf(o) == _keyOf(target));
}
void _focusWhere() {
final target = _at(5);
if (target == null) return;
_controller.focusWhere((o) => _keyOf(o) == _keyOf(target));
}
}
// ---------------------------------------------------------------------------
// Footer / empty / error builders (ClubApp-styled)
// ---------------------------------------------------------------------------
class _LoadMore extends StatelessWidget {
const _LoadMore();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(18),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.4, color: CA.violet)),
SizedBox(width: 10),
Text('Loading more…', style: TextStyle(fontSize: 12, color: CA.ink500)),
]),
);
}
class _NoMore extends StatelessWidget {
const _NoMore();
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(18),
child: Row(children: [
Expanded(child: Divider(color: CA.ink100)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.check_rounded, size: 14, color: CA.success),
SizedBox(width: 6),
Text('You are all caught up', style: TextStyle(fontSize: 11, color: CA.ink400)),
]),
),
Expanded(child: Divider(color: CA.ink100)),
]),
);
}
/// Optional reach-end footer (shown when `hasReachedEnd` is true and the
/// `reachEndBuilder` slot is wired). Takes precedence over [_NoMore].
class _ReachEnd extends StatelessWidget {
const _ReachEnd();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 12),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: CA.violetSoft,
borderRadius: BorderRadius.circular(999),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.flag_rounded, size: 14, color: CA.violet),
SizedBox(width: 7),
Text('No more items',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: CA.violet)),
]),
),
),
);
}
class _Empty extends StatelessWidget {
const _Empty({required this.onRefresh, required this.dataset});
final Future<void> Function() onRefresh;
final String dataset;
@override
Widget build(BuildContext context) {
final label = dataset == 'members' ? 'members' : 'posts';
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 92,
height: 92,
decoration: BoxDecoration(color: CA.violet.withOpacity(0.08), borderRadius: BorderRadius.circular(26)),
alignment: Alignment.center,
child: Container(
width: 54,
height: 54,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18), boxShadow: CA.cardShadow),
child: const Icon(Icons.inbox_outlined, color: CA.violet),
),
),
const SizedBox(height: 16),
Text('No $label found yet', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
const SizedBox(height: 6),
SizedBox(
width: 240,
child: Text('Looks like you do not have any $label yet. You can refresh anytime.',
textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
),
const SizedBox(height: 16),
FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRefresh, child: const Text('Refresh')),
]),
);
}
}
class _Error extends StatelessWidget {
const _Error({required this.error, required this.onRetry});
final Object error;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(color: CA.danger.withOpacity(0.1), borderRadius: BorderRadius.circular(22)),
child: const Icon(Icons.wifi_off_rounded, color: CA.danger, size: 28),
),
const SizedBox(height: 16),
const Text('Something went wrong', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
const SizedBox(height: 6),
SizedBox(
width: 260,
child: Text('$error'.replaceAll('Exception: ', ''), textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
),
const SizedBox(height: 16),
FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRetry, child: const Text('Retry')),
]),
);
}
}