Feed DataSource Expert - Paged Lists & Infinite Scroll
What: Pattern for paginated, reactive list/feed widgets using ValueNotifiers and Commands. Integrates with proxy pattern for entity lifecycle management.
CRITICAL RULES
- Auto-pagination triggers at
items.length - 3 (not at the last item)
updateDataCommand for initial/refresh loads, requestNextPageCommand for pagination - separate commands
- When refreshing with proxies: release OLD proxies AFTER replacing with new ones (delay release for animations)
itemCount is a ValueNotifier - watch it to rebuild the list widget
- Feed data sources are typically created with
createOnce in widgets, NOT registered in get_it
getItemAtIndex(index) both returns the item AND triggers auto-pagination
Base FeedDataSource
Non-paged feed for finite data sets:
dart
1abstract class FeedDataSource<TItem> {
2 FeedDataSource({List<TItem>? initialItems})
3 : items = initialItems ?? [];
4
5 final List<TItem> items;
6 final _itemCount = CustomValueNotifier<int>(0);
7 ValueListenable<int> get itemCount => _itemCount;
8 bool updateWasCalled = false;
9
10 late final updateDataCommand = Command.createAsyncNoParamNoResult(
11 () async {
12 await updateFeedData();
13 updateWasCalled = true;
14 refreshItemCount();
15 },
16 errorFilter: const LocalOnlyErrorFilter(),
17 );
18
19 ValueListenable<bool> get isFetchingNextPage => updateDataCommand.isRunning;
20 ValueListenable<CommandError?> get commandErrors => updateDataCommand.errors;
21
22 /// Subclasses implement - fetch data and populate items list
23 Future<void> updateFeedData();
24
25 /// Subclasses implement - compare items for deduplication
26 bool itemsAreEqual(TItem item1, TItem item2);
27
28 TItem getItemAtIndex(int index) {
29 assert(index >= 0 && index < items.length);
30 return items[index];
31 }
32
33 void refreshItemCount() {
34 _itemCount.value = items.length;
35 }
36
37 void addItemAtStart(TItem item) {
38 items.insert(0, item);
39 refreshItemCount();
40 }
41
42 void removeObject(TItem itemToRemove) {
43 items.removeWhere((item) => itemsAreEqual(item, itemToRemove));
44 refreshItemCount();
45 }
46
47 void reset() {
48 items.clear();
49 updateWasCalled = false;
50 refreshItemCount();
51 }
52
53 void dispose() {
54 _itemCount.dispose();
55 }
56}
PagedFeedDataSource
Extends FeedDataSource with cursor-based pagination:
dart
1abstract class PagedFeedDataSource<TItem> extends FeedDataSource<TItem> {
2 String? nextPageUrl;
3 bool? datasetExpired;
4
5 bool get hasNextPage => nextPageUrl != null && datasetExpired != true;
6
7 late final requestNextPageCommand = Command.createAsyncNoParamNoResult(
8 () async {
9 await requestNextPage();
10 refreshItemCount();
11 },
12 errorFilter: const LocalOnlyErrorFilter(),
13 );
14
15 /// Subclasses implement - fetch next page and append to items
16 Future<void> requestNextPage();
17
18 /// Call after parsing API response to store next page URL
19 void extractNextPageParams(String? url) {
20 nextPageUrl = url;
21 }
22
23 /// Auto-pagination: triggers when scrolling near the end
24 @override
25 TItem getItemAtIndex(int index) {
26 if (index >= items.length - 3 &&
27 commandErrors.value == null &&
28 hasNextPage &&
29 !requestNextPageCommand.isRunning.value) {
30 requestNextPageCommand.run();
31 }
32 return super.getItemAtIndex(index);
33 }
34
35 // Merged loading/error state from both commands
36 late final ValueNotifier<bool> _isFetchingNextPage = ValueNotifier(false);
37 @override
38 ValueListenable<bool> get isFetchingNextPage => _isFetchingNextPage;
39
40 // Listen to both commands and merge their isRunning states
41 // _isFetchingNextPage.value = updateDataCommand.isRunning.value ||
42 // requestNextPageCommand.isRunning.value;
43
44 @override
45 void reset() {
46 nextPageUrl = null;
47 datasetExpired = null;
48 super.reset();
49 }
50}
Concrete Implementation with Proxies
dart
1class PostsFeedSource extends PagedFeedDataSource<PostProxy> {
2 PostsFeedSource(this.feedType);
3 final PostFeedType feedType;
4
5 @override
6 bool itemsAreEqual(PostProxy a, PostProxy b) => a.id == b.id;
7
8 @override
9 Future<void> updateFeedData() async {
10 final api = PostApi(di<ApiClient>());
11 final response = await api.getPosts(type: feedType);
12 if (response == null) return;
13
14 // Release old proxies (delay for exit animations)
15 final oldItems = List<PostProxy>.from(items);
16 items.clear();
17
18 // Create new proxies via manager (increments ref count)
19 final proxies = di<PostsManager>().createProxies(response.data);
20 items.addAll(proxies);
21 extractNextPageParams(response.links?.next);
22
23 // Release old proxies after animations complete
24 Future.delayed(const Duration(milliseconds: 1000), () {
25 di<PostsManager>().releaseProxies(oldItems);
26 });
27 }
28
29 @override
30 Future<void> requestNextPage() async {
31 if (nextPageUrl == null) return;
32 final response = await callNextPageWithUrl<PostListResponse>(nextPageUrl!);
33 if (response == null) return;
34
35 final proxies = di<PostsManager>().createProxies(response.data);
36 items.addAll(proxies);
37 extractNextPageParams(response.links?.next);
38 }
39
40 // Override to manage reference counting on individual operations
41 @override
42 void addItemAtStart(PostProxy item) {
43 item.incrementReferenceCount();
44 super.addItemAtStart(item);
45 }
46
47 @override
48 void removeObject(PostProxy item) {
49 super.removeObject(item);
50 di<PostsManager>().releaseProxy(item);
51 }
52}
dart
1class FeedView<TItem> extends WatchingWidget {
2 const FeedView({
3 required this.feedSource,
4 required this.itemBuilder,
5 this.emptyListWidget,
6 });
7
8 final FeedDataSource<TItem> feedSource;
9 final Widget Function(BuildContext, TItem) itemBuilder;
10 final Widget? emptyListWidget;
11
12 @override
13 Widget build(BuildContext context) {
14 final itemCount = watch(feedSource.itemCount).value;
15 final isFetching = watch(feedSource.isFetchingNextPage).value;
16
17 // Trigger initial load
18 callOnce((_) => feedSource.updateDataCommand.run());
19
20 // Error handler
21 registerHandler(
22 target: feedSource.commandErrors,
23 handler: (context, error, _) {
24 showErrorSnackbar(context, error.error);
25 },
26 );
27
28 // Error state with retry
29 if (feedSource.commandErrors.value != null && itemCount == 0) {
30 return ErrorWidget(
31 onRetry: () => feedSource.updateDataCommand.run(),
32 );
33 }
34
35 // Initial loading
36 if (!feedSource.updateWasCalled && isFetching) {
37 return Center(child: CircularProgressIndicator());
38 }
39
40 // Empty state
41 if (itemCount == 0 && feedSource.updateWasCalled) {
42 return emptyListWidget ?? Text('No items');
43 }
44
45 // List with pull-to-refresh
46 return RefreshIndicator(
47 onRefresh: () => feedSource.updateDataCommand.runAsync(),
48 child: ListView.builder(
49 itemCount: itemCount + (isFetching ? 1 : 0),
50 itemBuilder: (context, index) {
51 if (index >= itemCount) {
52 return Center(child: CircularProgressIndicator());
53 }
54 // getItemAtIndex auto-triggers pagination near end
55 final item = feedSource.getItemAtIndex(index);
56 return itemBuilder(context, item);
57 },
58 ),
59 );
60 }
61}
Creation Pattern
dart
1// Create with createOnce in the widget that owns the feed
2class PostsFeedPage extends WatchingWidget {
3 @override
4 Widget build(BuildContext context) {
5 final feedSource = createOnce(
6 () => PostsFeedSource(PostFeedType.latest),
7 dispose: (source) => source.dispose(),
8 );
9
10 return FeedView<PostProxy>(
11 feedSource: feedSource,
12 itemBuilder: (context, post) => PostCard(post: post),
13 emptyListWidget: Text('No posts yet'),
14 );
15 }
16}
Filtered Feeds
Same data, different views via filter functions:
dart
1class ChatsListSource extends PagedFeedDataSource<ChatProxy> {
2 ChatFilterType _filter = ChatFilterType.ALL;
3 String _query = '';
4
5 void setTypeFilter(ChatFilterType filter) {
6 _filter = filter;
7 updateDataCommand.run(); // Re-fetch with new filter
8 }
9
10 void setSearchQuery(String query) {
11 _query = query;
12 updateDataCommand.run();
13 }
14}
Event Bus Integration
Feeds can react to events from other parts of the app:
dart
1// In FeedDataSource constructor
2di<EventBus>().on<FeedEvent>().listen((event) {
3 if (event.feedsToApply.contains(feedId)) {
4 switch (event.action) {
5 case FeedEventActions.update:
6 updateDataCommand.run();
7 case FeedEventActions.addItem:
8 addItemAtStart(event.data as TItem);
9 case FeedEventActions.removeItem:
10 removeObject(event.data as TItem);
11 }
12 }
13});
14
15// Trigger from anywhere in the app
16di<EventBus>().fire(FeedEvent(
17 action: FeedEventActions.addItem,
18 data: newPostProxy,
19 feedsToApply: [FeedIds.latestPostsFeed, FeedIds.followingPostsFeed],
20));
Anti-Patterns
dart
1// ❌ Releasing proxies immediately on refresh (breaks exit animations)
2items.clear();
3di<Manager>().releaseProxies(oldItems); // Widgets still animating!
4items.addAll(newProxies);
5
6// ✅ Delay release for animations
7final oldItems = List.from(items);
8items.clear();
9items.addAll(newProxies);
10Future.delayed(Duration(milliseconds: 1000), () {
11 di<Manager>().releaseProxies(oldItems);
12});
13
14// ❌ Registering feed in get_it as singleton
15di.registerSingleton<PostsFeed>(PostsFeedSource());
16// ✅ Create with createOnce in the widget that owns it
17final feed = createOnce(() => PostsFeedSource());
18
19// ❌ Manually checking scroll position for pagination
20scrollController.addListener(() {
21 if (scrollController.position.pixels >= ...) loadMore();
22});
23// ✅ Auto-pagination via getItemAtIndex triggers at length - 3
24
25// ❌ Single command for both initial load and pagination
26// ✅ Separate commands: updateDataCommand + requestNextPageCommand
27// Allows independent loading/error states and restrictions