skills/feed-datasource-expert/SKILL.md
Expert guidance on implementing paginated feeds and infinite scroll in Flutter using FeedDataSource and PagedFeedDataSource patterns. Covers base feed data source, cursor-based pagination, auto-pagination at length-3, proxy lifecycle with reference counting, feed widget implementation, filtered feeds, event bus integration, and creation with createOnce. Use when building paginated lists, infinite scroll, feed views, or managing proxy lifecycle in feeds.
npx skillsauth add flutter-it/watch_it feed-datasource-expertInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
What: Pattern for paginated, reactive list/feed widgets using ValueNotifiers and Commands. Integrates with proxy pattern for entity lifecycle management.
items.length - 3 (not at the last item)updateDataCommand for initial/refresh loads, requestNextPageCommand for pagination - separate commandsitemCount is a ValueNotifier - watch it to rebuild the list widgetcreateOnce in widgets, NOT registered in get_itgetItemAtIndex(index) both returns the item AND triggers auto-paginationNon-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();
}
}
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
@override
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);
@override
ValueListenable<bool> get isFetchingNextPage => _isFetchingNextPage;
// Listen to both commands and merge their isRunning states
// _isFetchingNextPage.value = updateDataCommand.isRunning.value ||
// requestNextPageCommand.isRunning.value;
@override
void reset() {
nextPageUrl = null;
datasetExpired = null;
super.reset();
}
}
class PostsFeedSource extends PagedFeedDataSource<PostProxy> {
PostsFeedSource(this.feedType);
final PostFeedType feedType;
@override
bool itemsAreEqual(PostProxy a, PostProxy b) => a.id == b.id;
@override
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);
});
}
@override
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
@override
void addItemAtStart(PostProxy item) {
item.incrementReferenceCount();
super.addItemAtStart(item);
}
@override
void removeObject(PostProxy item) {
super.removeObject(item);
di<PostsManager>().releaseProxy(item);
}
}
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;
@override
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);
},
),
);
}
}
// Create with createOnce in the widget that owns the feed
class PostsFeedPage extends WatchingWidget {
@override
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'),
);
}
}
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();
}
}
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],
));
// ❌ 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
development
Expert guidance on watch_it reactive widget state management for Flutter. Covers watch functions (watch, watchIt, watchValue, watchStream, watchFuture), handler registration (registerHandler, registerStreamHandler, registerFutureHandler), lifecycle functions (callOnce, createOnce, onDispose), ordering rules, widget granularity, and startup orchestration. Use when building reactive widgets with watch_it, watching ValueListenables/Streams/Futures, or managing widget-scoped state.
testing
Expert guidance on get_it service locator and dependency injection for Flutter/Dart. Covers registration (singleton, factory, lazy, async), scopes with shadowing, async initialization with init() pattern, retrieval, testing with scope-based mocking, and production patterns. Use when working with get_it, dependency injection, service registration, scopes, or async initialization.
testing
Architecture guidance for Flutter apps using the flutter_it construction set (get_it, watch_it, command_it, listen_it). Covers Pragmatic Flutter Architecture (PFA) with Services/Managers/Views, feature-based project structure, manager pattern, proxy pattern with optimistic updates and override fields, DataRepository with reference counting, scoped services, widget granularity, testing, and best practices. Use when designing app architecture, structuring Flutter projects, implementing managers or proxies, or planning feature organization.
development
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.