scrolled_pagination 1.4.2
scrolled_pagination: ^1.4.2 copied to clipboard
A reusable MVC-style infinite-scroll pagination toolkit for Flutter with multiple providers (Future/Stream/sync), page or cursor-based pagination, reach-end state, item operations, batch transactions, [...]
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;
String lastEvent = '—';
SortManager<Object>? _sort;
static const double _canvasWidth = 1460;
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'),
);
}
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 String tag,
required PaginatedItemBuilder<Object> itemBuilder,
required EdgeInsets padding,
required double gap,
required int crossAxisCount,
required double childAspectRatio,
required bool pullToRefresh,
}) {
return PaginatedScrollView<Object>(
key: ValueKey('$tag|$dataset|$mode|$kind|$layout'),
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,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: CA.canvas,
body: SafeArea(
child: SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 32),
child: SizedBox(
width: _canvasWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(),
const SizedBox(height: 16),
_controls(),
const SizedBox(height: 16),
_actions(),
const SizedBox(height: 18),
_stage(),
const SizedBox(height: 16),
const StatesLegend(),
],
),
),
),
),
),
),
);
}
// --- header -----------------------------------------------------------------
Widget _header() => 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: 23, 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)),
],
),
),
const SizedBox(width: 24),
StatusPill(controller: _controller, lastEvent: lastEvent),
],
);
// --- segmented controls -----------------------------------------------------
Widget _divider() => Container(width: 1, color: CA.ink100, margin: const EdgeInsets.symmetric(vertical: 2));
Widget _controls() => Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: BoxDecoration(
color: CA.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: CA.cardShadow,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
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(width: 16),
_divider(),
const SizedBox(width: 16),
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(width: 16),
_divider(),
const SizedBox(width: 16),
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(width: 16),
_divider(),
const SizedBox(width: 16),
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(width: 16),
_divider(),
const SizedBox(width: 16),
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(); },
),
],
),
),
);
// --- action rows ------------------------------------------------------------
Object? _at(int i) => (i >= 0 && i < _controller.count) ? _controller.items[i] : null;
Widget _actions() => Column(
children: [
ActionRow(label: 'Loading', children: [
ActionButton('Refresh', tone: ActTone.accent, icon: Icons.refresh, onTap: _controller.refresh),
ActionButton('Load more', onTap: _controller.loadMore),
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('Clear', onTap: _controller.clear),
]),
ActionRow(label: 'Add', children: [
ActionButton('+ Prepend', onTap: () => _controller.addItem(_synthetic(), prepend: true)),
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()])),
]),
ActionRow(label: 'Update', children: [
ActionButton('updateItem first', onTap: _updateFirst),
ActionButton('updateWhere', onTap: _updateWhere),
ActionButton('updateFirstWhere', onTap: _updateFirstWhere),
ActionButton('updateAll', onTap: _updateAll),
]),
ActionRow(label: 'Replace', 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);
}),
]),
ActionRow(label: 'Remove', 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),
]),
ActionRow(label: 'Reorder', 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)),
]),
ActionRow(label: 'Batch', 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),
]),
ActionRow(label: 'Sort', children: [
ActionButton('Flip order + resort', icon: Icons.swap_vert, onTap: _flipSort),
]),
ActionRow(label: 'Select', 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),
]),
ActionRow(label: 'Scroll', 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),
]),
ActionRow(label: 'Focus', 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),
]),
],
);
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));
}
// --- stage: phone + browser + code reference --------------------------------
Widget _stage() {
final feed = dataset != 'members';
final phoneTitle = feed ? 'News & articles' : 'Members';
final phoneSub = '${mode == 'cursor' ? 'cursor-based' : 'page-based'} · $kind';
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PhoneFrame(
title: phoneTitle,
subtitle: phoneSub,
child: _view(
tag: 'phone',
itemBuilder: _phoneItem,
padding: const EdgeInsets.all(14),
gap: 12,
crossAxisCount: 2,
childAspectRatio: feed ? 0.74 : 0.85,
pullToRefresh: true,
),
),
const SizedBox(width: 22),
Expanded(
child: Column(
children: [
BrowserWindow(
url: 'app.clubapp.com / ${feed ? 'feed' : 'members'}',
height: 560,
child: _view(
tag: 'browser',
itemBuilder: _browserItem,
padding: feed
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.all(16),
gap: feed ? 0 : 12,
crossAxisCount: 3,
childAspectRatio: feed ? 0.9 : 1.0,
pullToRefresh: false,
),
),
const SizedBox(height: 18),
const CodeReferencePanel(height: 250),
],
),
),
],
);
}
}
// ---------------------------------------------------------------------------
// 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) => Padding(
padding: const EdgeInsets.all(18),
child: Row(children: const [
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: Row(mainAxisSize: MainAxisSize.min, children: const [
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')),
]),
);
}
}