Skip to content

Commit 6ff89fa

Browse files
committed
ai gen scrollable "paginated" listview
1 parent 35f4ea9 commit 6ff89fa

1 file changed

Lines changed: 388 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
import "package:flutter/widgets.dart";
2+
3+
/// A page of results returned from [InfiniteScrollListView.fetchPage].
4+
///
5+
/// Set [nextPageKey] to null to signal that this is the last page.
6+
class InfiniteScrollPage<T, K> {
7+
InfiniteScrollPage({required this.items, required this.nextPageKey});
8+
9+
final List<T> items;
10+
final K? nextPageKey;
11+
}
12+
13+
/// Triggers refresh and retry on an [InfiniteScrollListView] from outside.
14+
///
15+
/// Create one in the parent's state, pass it to
16+
/// [InfiniteScrollListView.controller], and call [refresh] when search/filter
17+
/// state changes. In-flight fetches from before the refresh are discarded
18+
/// when they complete.
19+
class InfiniteScrollListController {
20+
VoidCallback? _onRefresh;
21+
VoidCallback? _onRetry;
22+
23+
void _attach({
24+
required VoidCallback onRefresh,
25+
required VoidCallback onRetry,
26+
}) {
27+
_onRefresh = onRefresh;
28+
_onRetry = onRetry;
29+
}
30+
31+
void _detach() {
32+
_onRefresh = null;
33+
_onRetry = null;
34+
}
35+
36+
/// Discard current items and reload from the first page.
37+
void refresh() => _onRefresh?.call();
38+
39+
/// Retry the last failed fetch.
40+
void retry() => _onRetry?.call();
41+
}
42+
43+
/// The load lifecycle of an [InfiniteScrollListView]. A sealed type so all
44+
/// transitions are explicit and the compiler enforces exhaustive handling.
45+
sealed class _Status<K> {
46+
const _Status();
47+
}
48+
49+
class _LoadingFirstPageStatus<K> extends _Status<K> {
50+
const _LoadingFirstPageStatus();
51+
}
52+
53+
class _LoadingMoreStatus<K> extends _Status<K> {
54+
const _LoadingMoreStatus();
55+
}
56+
57+
class _IdleStatus<K> extends _Status<K> {
58+
const _IdleStatus({required this.nextPageKey});
59+
60+
/// Null means there are no more pages.
61+
final K? nextPageKey;
62+
}
63+
64+
class _FailedFirstPageStatus<K> extends _Status<K> {
65+
const _FailedFirstPageStatus({required this.error, required this.pageKey});
66+
final Object error;
67+
final K pageKey;
68+
}
69+
70+
class _FailedMoreStatus<K> extends _Status<K> {
71+
const _FailedMoreStatus({required this.error, required this.pageKey});
72+
final Object error;
73+
final K pageKey;
74+
}
75+
76+
/// A generic infinite-scroll [ListView].
77+
///
78+
/// Works correctly with [shrinkWrap] as long as the parent provides bounded
79+
/// height (e.g. inside a [Flexible] or sized container).
80+
///
81+
/// Search/filter changes should be applied by updating any state your
82+
/// [fetchPage] closure reads, then calling
83+
/// [InfiniteScrollListController.refresh].
84+
class InfiniteScrollListView<T, K> extends StatefulWidget {
85+
const InfiniteScrollListView({
86+
super.key,
87+
required this.firstPageKey,
88+
required this.fetchPage,
89+
required this.itemBuilder,
90+
this.controller,
91+
this.separatorBuilder,
92+
this.firstPageProgressBuilder,
93+
this.newPageProgressBuilder,
94+
this.firstPageErrorBuilder,
95+
this.newPageErrorBuilder,
96+
this.emptyBuilder,
97+
this.noMoreItemsBuilder,
98+
this.padding,
99+
this.shrinkWrap = false,
100+
this.physics,
101+
this.prefetchThreshold = 200,
102+
});
103+
104+
/// Key passed to [fetchPage] for the very first page.
105+
final K firstPageKey;
106+
107+
/// Fetches a page. Return an [InfiniteScrollPage] with
108+
/// [InfiniteScrollPage.nextPageKey] set to null on the last page.
109+
final Future<InfiniteScrollPage<T, K>> Function(K pageKey) fetchPage;
110+
111+
/// Builds a single data item.
112+
final Widget Function(BuildContext context, T item, int index) itemBuilder;
113+
114+
final InfiniteScrollListController? controller;
115+
116+
/// Optional separator builder. Called between data items only (not around
117+
/// the footer).
118+
final Widget Function(BuildContext context, int index)? separatorBuilder;
119+
120+
final WidgetBuilder? firstPageProgressBuilder;
121+
final WidgetBuilder? newPageProgressBuilder;
122+
final Widget Function(BuildContext context, Object error, VoidCallback retry)?
123+
firstPageErrorBuilder;
124+
final Widget Function(BuildContext context, Object error, VoidCallback retry)?
125+
newPageErrorBuilder;
126+
final WidgetBuilder? emptyBuilder;
127+
final WidgetBuilder? noMoreItemsBuilder;
128+
129+
final EdgeInsetsGeometry? padding;
130+
final bool shrinkWrap;
131+
final ScrollPhysics? physics;
132+
133+
/// Pixels from the bottom at which the next page begins fetching.
134+
final double prefetchThreshold;
135+
136+
@override
137+
State<InfiniteScrollListView<T, K>> createState() =>
138+
_InfiniteScrollListViewState<T, K>();
139+
}
140+
141+
class _InfiniteScrollListViewState<T, K>
142+
extends State<InfiniteScrollListView<T, K>> {
143+
final ScrollController _scrollController = ScrollController();
144+
final List<T> _items = [];
145+
146+
_Status<K> _status = _LoadingFirstPageStatus<K>();
147+
148+
/// Incremented on every refresh. Each fetch captures the value at its start;
149+
/// if the captured value differs from the current value when the fetch
150+
/// completes, the result is discarded.
151+
int _generation = 0;
152+
153+
@override
154+
void initState() {
155+
super.initState();
156+
_scrollController.addListener(_onScroll);
157+
widget.controller?._attach(onRefresh: _refresh, onRetry: _retry);
158+
// Status defaults to _LoadingFirstPage so _runFetch can be called directly.
159+
_runFetch(widget.firstPageKey);
160+
}
161+
162+
@override
163+
void didUpdateWidget(covariant InfiniteScrollListView<T, K> oldWidget) {
164+
super.didUpdateWidget(oldWidget);
165+
if (oldWidget.controller != widget.controller) {
166+
oldWidget.controller?._detach();
167+
widget.controller?._attach(onRefresh: _refresh, onRetry: _retry);
168+
}
169+
}
170+
171+
@override
172+
void dispose() {
173+
widget.controller?._detach();
174+
_scrollController.removeListener(_onScroll);
175+
_scrollController.dispose();
176+
super.dispose();
177+
}
178+
179+
/// Transition status to a loading variant and start a fetch.
180+
void _fetch(K pageKey) {
181+
setState(() {
182+
_status = _items.isEmpty
183+
? _LoadingFirstPageStatus<K>()
184+
: _LoadingMoreStatus<K>();
185+
});
186+
_runFetch(pageKey);
187+
}
188+
189+
/// Run a fetch without changing status. Used for the initial fetch and
190+
/// when auto-continuing past an empty page (status is already loading).
191+
Future<void> _runFetch(K pageKey) async {
192+
final generation = _generation;
193+
final wasFirstPage = _items.isEmpty;
194+
195+
try {
196+
final result = await widget.fetchPage(pageKey);
197+
if (!mounted || generation != _generation) return;
198+
199+
// Empty page but more pages remain: continue immediately, staying in
200+
// the loading state. (A buggy backend returning unbounded empty pages
201+
// will hammer the API here.)
202+
if (result.items.isEmpty && result.nextPageKey != null) {
203+
return _runFetch(result.nextPageKey as K);
204+
}
205+
206+
setState(() {
207+
_items.addAll(result.items);
208+
_status = _IdleStatus<K>(nextPageKey: result.nextPageKey);
209+
});
210+
211+
// First page may not fill the viewport. After layout, if the list
212+
// still isn't scrollable and more pages exist, fetch the next.
213+
_maybeFetchIfUnderfilled();
214+
} catch (error) {
215+
if (!mounted || generation != _generation) return;
216+
setState(() {
217+
_status = wasFirstPage
218+
? _FailedFirstPageStatus<K>(error: error, pageKey: pageKey)
219+
: _FailedMoreStatus<K>(error: error, pageKey: pageKey);
220+
});
221+
}
222+
}
223+
224+
void _maybeFetchIfUnderfilled() {
225+
WidgetsBinding.instance.addPostFrameCallback((_) {
226+
if (!mounted) return;
227+
if (_status case _IdleStatus<K>(nextPageKey: final next?)
228+
when _scrollController.hasClients &&
229+
_scrollController.position.maxScrollExtent <= 0) {
230+
_fetch(next);
231+
}
232+
});
233+
}
234+
235+
void _onScroll() {
236+
if (!_scrollController.hasClients) return;
237+
if (_status case _IdleStatus<K>(nextPageKey: final next?)) {
238+
final position = _scrollController.position;
239+
if (position.pixels >=
240+
position.maxScrollExtent - widget.prefetchThreshold) {
241+
_fetch(next);
242+
}
243+
}
244+
}
245+
246+
void _refresh() {
247+
_generation++;
248+
setState(() {
249+
_items.clear();
250+
_status = _LoadingFirstPageStatus<K>();
251+
});
252+
_runFetch(widget.firstPageKey);
253+
}
254+
255+
void _retry() {
256+
if (_status
257+
case _FailedFirstPageStatus<K>(:final pageKey) ||
258+
_FailedMoreStatus<K>(:final pageKey)) {
259+
_fetch(pageKey);
260+
}
261+
}
262+
263+
@override
264+
Widget build(BuildContext context) {
265+
if (_items.isEmpty) {
266+
return switch (_status) {
267+
_LoadingFirstPageStatus<K>() =>
268+
widget.firstPageProgressBuilder?.call(context) ??
269+
const _DefaultFirstPageProgress(),
270+
_FailedFirstPageStatus<K>(:final error) =>
271+
widget.firstPageErrorBuilder?.call(context, error, _retry) ??
272+
_DefaultErrorView(error: error, onRetry: _retry),
273+
_IdleStatus<K>() =>
274+
widget.emptyBuilder?.call(context) ?? const _DefaultEmpty(),
275+
// Defensive: these variants cannot occur with no items.
276+
_LoadingMoreStatus<K>() ||
277+
_FailedMoreStatus<K>() => const SizedBox.shrink(),
278+
};
279+
}
280+
281+
final Widget? footer = switch (_status) {
282+
_LoadingMoreStatus<K>() =>
283+
widget.newPageProgressBuilder?.call(context) ??
284+
const _DefaultNewPageProgress(),
285+
_FailedMoreStatus<K>(:final error) =>
286+
widget.newPageErrorBuilder?.call(context, error, _retry) ??
287+
_DefaultErrorView(error: error, onRetry: _retry),
288+
_IdleStatus<K>(nextPageKey: null) => widget.noMoreItemsBuilder?.call(
289+
context,
290+
),
291+
_IdleStatus<K>() =>
292+
widget.newPageProgressBuilder?.call(context) ??
293+
const _DefaultNewPageProgress(),
294+
// Defensive: these variants cannot occur with items present.
295+
_LoadingFirstPageStatus<K>() || _FailedFirstPageStatus<K>() => null,
296+
};
297+
298+
final itemCount = _items.length + (footer != null ? 1 : 0);
299+
300+
return NotificationListener(
301+
onNotification: (_) {
302+
_maybeFetchIfUnderfilled();
303+
return false;
304+
},
305+
child: ListView.separated(
306+
controller: _scrollController,
307+
primary: false,
308+
shrinkWrap: widget.shrinkWrap,
309+
physics: widget.physics,
310+
padding: widget.padding,
311+
itemCount: itemCount,
312+
separatorBuilder: (context, index) {
313+
if (index == _items.length - 1 && footer != null) {
314+
return const SizedBox.shrink();
315+
}
316+
return widget.separatorBuilder?.call(context, index) ??
317+
const SizedBox.shrink();
318+
},
319+
itemBuilder: (context, index) {
320+
if (index < _items.length) {
321+
return widget.itemBuilder(context, _items[index], index);
322+
}
323+
return footer!;
324+
},
325+
),
326+
);
327+
}
328+
}
329+
330+
class _DefaultFirstPageProgress extends StatelessWidget {
331+
const _DefaultFirstPageProgress();
332+
333+
@override
334+
Widget build(BuildContext context) {
335+
return const Center(
336+
child: Padding(padding: EdgeInsets.all(24), child: Text("Loading...")),
337+
);
338+
}
339+
}
340+
341+
class _DefaultNewPageProgress extends StatelessWidget {
342+
const _DefaultNewPageProgress();
343+
344+
@override
345+
Widget build(BuildContext context) {
346+
return const Center(
347+
child: Padding(
348+
padding: EdgeInsets.all(16),
349+
child: Text("Loading more..."),
350+
),
351+
);
352+
}
353+
}
354+
355+
class _DefaultEmpty extends StatelessWidget {
356+
const _DefaultEmpty();
357+
358+
@override
359+
Widget build(BuildContext context) {
360+
return const Center(
361+
child: Padding(padding: EdgeInsets.all(24), child: Text("No items")),
362+
);
363+
}
364+
}
365+
366+
class _DefaultErrorView extends StatelessWidget {
367+
const _DefaultErrorView({required this.error, required this.onRetry});
368+
369+
final Object error;
370+
final VoidCallback onRetry;
371+
372+
@override
373+
Widget build(BuildContext context) {
374+
return Center(
375+
child: Padding(
376+
padding: const EdgeInsets.all(16),
377+
child: Column(
378+
mainAxisSize: MainAxisSize.min,
379+
children: [
380+
Text("$error"),
381+
const SizedBox(height: 8),
382+
GestureDetector(onTap: onRetry, child: const Text("Retry")),
383+
],
384+
),
385+
),
386+
);
387+
}
388+
}

0 commit comments

Comments
 (0)