feed-datasource-expert
SKILL.md
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) updateDataCommandfor initial/refresh loads,requestNextPageCommandfor pagination - separate commands- When refreshing with proxies: release OLD proxies AFTER replacing with new ones (delay release for animations)
itemCountis a ValueNotifier - watch it to rebuild the list widget- Feed data sources are typically created with
createOncein 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:
abstract class FeedDataSource<TItem> {
FeedDataSource({List<TItem>? initialItems})
: items = initialItems ?? [];
final List<TItem> items;
final _itemCount = CustomValueNotifier<int>(0);
ValueListenable<int> get itemCount => _itemCount;
bool updateWasCalled = false;
late final updateDataCommand = Command.createAsyncNoParamNoResult(
() async {
await updateFeedData();
updateWasCalled = true;
refreshItemCount();
},
errorFilter: const LocalOnlyErrorFilter(),
);
ValueListenable<bool> get isFetchingNextPage => updateDataCommand.isRunning;
ValueListenable<CommandError?> get commandErrors => updateDataCommand.errors;
/// Subclasses implement - fetch data and populate items list
Future<void> updateFeedData();
/// Subclasses implement - compare items for deduplication
bool itemsAreEqual(TItem item1, TItem item2);
TItem getItemAtIndex(int index) {
assert(index >= 0 && index < items.length);
return items[index];
}
void refreshItemCount() {
_itemCount.value = items.length;
}
void addItemAtStart(TItem item) {
items.insert(0, item);
refreshItemCount();
}
void removeObject(TItem itemToRemove) {
items.removeWhere((item) => itemsAreEqual(item, itemToRemove));
refreshItemCount();
}
void reset() {
items.clear();
updateWasCalled = false;
refreshItemCount();
}
void dispose() {
_itemCount.dispose();
}
}
PagedFeedDataSource
Extends FeedDataSource with cursor-based pagination:
abstract class PagedFeedDataSource<TItem> extends FeedDataSource<TItem> {
String? nextPageUrl;
bool? datasetExpired;
bool get hasNextPage => nextPageUrl != null && datasetExpired != true;
late final requestNextPageCommand = Command.createAsyncNoParamNoResult(
() async {
await requestNextPage();
refreshItemCount();
},
errorFilter: const LocalOnlyErrorFilter(),
);
/// Subclasses implement - fetch next page and append to items
Future<void> requestNextPage();
/// Call after parsing API response to store next page URL
void extractNextPageParams(String? url) {
nextPageUrl = url;
}
/// Auto-pagination: triggers when scrolling near the end
TItem getItemAtIndex(int index) {
if (index >= items.length - 3 &&
commandErrors.value == null &&
hasNextPage &&
!requestNextPageCommand.isRunning.value) {
requestNextPageCommand.run();
}
return super.getItemAtIndex(index);
}
// Merged loading/error state from both commands
late final ValueNotifier<bool> _isFetchingNextPage = ValueNotifier(false);
ValueListenable<bool> get isFetchingNextPage => _isFetchingNextPage;
// Listen to both commands and merge their isRunning states
// _isFetchingNextPage.value = updateDataCommand.isRunning.value ||
// requestNextPageCommand.isRunning.value;
void reset() {
nextPageUrl = null;
datasetExpired = null;
super.reset();
}
}
Concrete Implementation with Proxies
class PostsFeedSource extends PagedFeedDataSource<PostProxy> {
PostsFeedSource(this.feedType);
final PostFeedType feedType;
bool itemsAreEqual(PostProxy a, PostProxy b) => a.id == b.id;
Future<void> updateFeedData() async {
final api = PostApi(di<ApiClient>());
final response = await api.getPosts(type: feedType);
if (response == null) return;
// Release old proxies (delay for exit animations)
final oldItems = List<PostProxy>.from(items);
items.clear();
// Create new proxies via manager (increments ref count)
final proxies = di<PostsManager>().createProxies(response.data);
items.addAll(proxies);
extractNextPageParams(response.links?.next);
// Release old proxies after animations complete
Future.delayed(const Duration(milliseconds: 1000), () {
di<PostsManager>().releaseProxies(oldItems);
});
}
Future<void> requestNextPage() async {
if (nextPageUrl == null) return;
final response = await callNextPageWithUrl<PostListResponse>(nextPageUrl!);
if (response == null) return;
final proxies = di<PostsManager>().createProxies(response.data);
items.addAll(proxies);
extractNextPageParams(response.links?.next);
}
// Override to manage reference counting on individual operations
void addItemAtStart(PostProxy item) {
item.incrementReferenceCount();
super.addItemAtStart(item);
}
void removeObject(PostProxy item) {
super.removeObject(item);
di<PostsManager>().releaseProxy(item);
}
}
Feed Widget
class FeedView<TItem> extends WatchingWidget {
const FeedView({
required this.feedSource,
required this.itemBuilder,
this.emptyListWidget,
});
final FeedDataSource<TItem> feedSource;
final Widget Function(BuildContext, TItem) itemBuilder;
final Widget? emptyListWidget;
Widget build(BuildContext context) {
final itemCount = watch(feedSource.itemCount).value;
final isFetching = watch(feedSource.isFetchingNextPage).value;
// Trigger initial load
callOnce((_) => feedSource.updateDataCommand.run());
// Error handler
registerHandler(
target: feedSource.commandErrors,
handler: (context, error, _) {
showErrorSnackbar(context, error.error);
},
);
// Error state with retry
if (feedSource.commandErrors.value != null && itemCount == 0) {
return ErrorWidget(
onRetry: () => feedSource.updateDataCommand.run(),
);
}
// Initial loading
if (!feedSource.updateWasCalled && isFetching) {
return Center(child: CircularProgressIndicator());
}
// Empty state
if (itemCount == 0 && feedSource.updateWasCalled) {
return emptyListWidget ?? Text('No items');
}
// List with pull-to-refresh
return RefreshIndicator(
onRefresh: () => feedSource.updateDataCommand.runAsync(),
child: ListView.builder(
itemCount: itemCount + (isFetching ? 1 : 0),
itemBuilder: (context, index) {
if (index >= itemCount) {
return Center(child: CircularProgressIndicator());
}
// getItemAtIndex auto-triggers pagination near end
final item = feedSource.getItemAtIndex(index);
return itemBuilder(context, item);
},
),
);
}
}
Creation Pattern
// Create with createOnce in the widget that owns the feed
class PostsFeedPage extends WatchingWidget {
Widget build(BuildContext context) {
final feedSource = createOnce(
() => PostsFeedSource(PostFeedType.latest),
dispose: (source) => source.dispose(),
);
return FeedView<PostProxy>(
feedSource: feedSource,
itemBuilder: (context, post) => PostCard(post: post),
emptyListWidget: Text('No posts yet'),
);
}
}
Filtered Feeds
Same data, different views via filter functions:
class ChatsListSource extends PagedFeedDataSource<ChatProxy> {
ChatFilterType _filter = ChatFilterType.ALL;
String _query = '';
void setTypeFilter(ChatFilterType filter) {
_filter = filter;
updateDataCommand.run(); // Re-fetch with new filter
}
void setSearchQuery(String query) {
_query = query;
updateDataCommand.run();
}
}
Event Bus Integration
Feeds can react to events from other parts of the app:
// In FeedDataSource constructor
di<EventBus>().on<FeedEvent>().listen((event) {
if (event.feedsToApply.contains(feedId)) {
switch (event.action) {
case FeedEventActions.update:
updateDataCommand.run();
case FeedEventActions.addItem:
addItemAtStart(event.data as TItem);
case FeedEventActions.removeItem:
removeObject(event.data as TItem);
}
}
});
// Trigger from anywhere in the app
di<EventBus>().fire(FeedEvent(
action: FeedEventActions.addItem,
data: newPostProxy,
feedsToApply: [FeedIds.latestPostsFeed, FeedIds.followingPostsFeed],
));
Anti-Patterns
// ❌ Releasing proxies immediately on refresh (breaks exit animations)
items.clear();
di<Manager>().releaseProxies(oldItems); // Widgets still animating!
items.addAll(newProxies);
// ✅ Delay release for animations
final oldItems = List.from(items);
items.clear();
items.addAll(newProxies);
Future.delayed(Duration(milliseconds: 1000), () {
di<Manager>().releaseProxies(oldItems);
});
// ❌ Registering feed in get_it as singleton
di.registerSingleton<PostsFeed>(PostsFeedSource());
// ✅ Create with createOnce in the widget that owns it
final feed = createOnce(() => PostsFeedSource());
// ❌ Manually checking scroll position for pagination
scrollController.addListener(() {
if (scrollController.position.pixels >= ...) loadMore();
});
// ✅ Auto-pagination via getItemAtIndex triggers at length - 3
// ❌ Single command for both initial load and pagination
// ✅ Separate commands: updateDataCommand + requestNextPageCommand
// Allows independent loading/error states and restrictions
Weekly Installs
2
Repository
flutter-it/flutter_itGitHub Stars
13
First Seen
2 days ago
Security Audits
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2