arch-atlas

Pagination part 4

Presentation (Integration)

17:11

Summary

This lesson implements the core pagination logic for the featured books list using a ScrollController. It demonstrates how to detect when the user has scrolled to 70% of the list, triggering a fetch for the next page of data. To prevent new data from replacing the existing list, the FeaturedBooksCubit is refactored to accumulate the fetched books in a local list and append new items. Finally, the lesson addresses debounce logic by introducing a loading flag in the widget state and creating specific pagination states to prevent the UI from rebuilding with a full-screen loader. This approach keeps state logic inside the Cubit, maintaining a clean separation of concerns.

Key Ideas

  • Pagination is triggered by attaching a listener to a ScrollController and checking if position.pixels exceeds 70% of maxScrollExtent.
  • The FetchFeaturedBooksUseCase and HomeRepo are updated to accept a pageNumber parameter for sequential fetching.
  • Google Books API utilizes startIndex for pagination, which is calculated dynamically as pageNumber * 10.
  • Local isLoading boolean acts as a debounce mechanism inside the widget to prevent duplicate API requests while a fetch is in progress.
  • FeaturedBooksCubit maintains an internal List<BookEntity> to accumulate books across multiple sequential requests.
  • books.addAll() is used to append newly fetched items to the accumulated list before emitting the success state.
  • Distinct states (FeaturedBooksPaginationLoading and FeaturedBooksPaginationFailure) are introduced to handle pagination loading without interrupting the active UI.

Code Snippets

Implementing the ScrollController and debounce logic in the presentation layerdart
class FeaturedBooksListView extends StatefulWidget {
  const FeaturedBooksListView({super.key, required this.books});
  final List<BookEntity> books;

  @override
  State<FeaturedBooksListView> createState() => _FeaturedBooksListViewState();
}

class _FeaturedBooksListViewState extends State<FeaturedBooksListView> {
  late final ScrollController _scrollController;
  int nextPage = 1;
  bool isLoading = false;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
    _scrollController.addListener(_scrollListener);
  }

  void _scrollListener() async {
    if (_scrollController.position.pixels >= 0.7 * _scrollController.position.maxScrollExtent) {
      if (!isLoading) {
        isLoading = true;
        await BlocProvider.of<FeaturedBooksCubit>(context).fetchFeaturedBooks(pageNumber: nextPage++);
        isLoading = false;
      }
    }
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      // ... list view implementation
    );
  }
}
Accumulating the fetched books in the Cubit and handling pagination statesdart
class FeaturedBooksCubit extends Cubit<FeaturedBooksState> {
  FeaturedBooksCubit(this.fetchFeaturedBooksUseCase) : super(FeaturedBooksInitial());

  final FetchFeaturedBooksUseCase fetchFeaturedBooksUseCase;
  List<BookEntity> books = [];

  Future<void> fetchFeaturedBooks({int pageNumber = 0}) async {
    if (pageNumber == 0) {
      emit(FeaturedBooksLoading());
    } else {
      emit(FeaturedBooksPaginationLoading());
    }

    var result = await fetchFeaturedBooksUseCase.call(pageNumber);

    result.fold(
      (failure) {
        if (pageNumber == 0) {
          emit(FeaturedBooksFailure(failure.errMessage));
        } else {
          emit(FeaturedBooksPaginationFailure(failure.errMessage));
        }
      },
      (newBooks) {
        books.addAll(newBooks);
        emit(FeaturedBooksSuccess(books));
      }
    );
  }
}
Modifying the repository to pass the pageNumber to the APIdart
@override
Future<Either<Failure, List<BookEntity>>> fetchFeaturedBooks({int pageNumber = 0}) async {
  try {
    var data = await apiService.get(
        endPoint: 'volumes?Filtering=free-ebooks&q=programming&startIndex=${pageNumber * 10}');
    List<BookEntity> books = [];
    for (var item in data['items']) {
      books.add(BookModel.fromJson(item));
    }
    return right(books);
  } catch (e) {
    if (e is DioException) {
      return left(ServerFailure.fromDioError(e));
    }
    return left(ServerFailure(e.toString()));
  }
}
Adding distinct states for pagination to avoid full-screen loadersdart
abstract class FeaturedBooksState {}

class FeaturedBooksInitial extends FeaturedBooksState {}

class FeaturedBooksLoading extends FeaturedBooksState {}
class FeaturedBooksPaginationLoading extends FeaturedBooksState {}

class FeaturedBooksFailure extends FeaturedBooksState {
  final String errMessage;
  FeaturedBooksFailure(this.errMessage);
}
class FeaturedBooksPaginationFailure extends FeaturedBooksState {
  final String errMessage;
  FeaturedBooksPaginationFailure(this.errMessage);
}

class FeaturedBooksSuccess extends FeaturedBooksState {
  final List<BookEntity> books;
  FeaturedBooksSuccess(this.books);
}

My Notes

  • State Accumulation Decision: The instructor actively debates where to store the accumulated list, eventually settling on the FeaturedBooksCubit. This is the correct Clean Architecture approach for Bloc/Cubit; the UI should purely consume the state, not manipulate it.
  • setState Avoidance: isLoading is used as a local boolean without wrapping it in setState. This is intentional and optimal. It acts purely as a synchronous lock inside the _scrollListener without triggering unnecessary UI rebuilds.
  • Future<void> in Cubit: [Editor's note] Making fetchFeaturedBooks return a Future<void> is critical. If it were standard void, the await in the UI's scroll listener would resolve instantly, breaking the debounce logic and causing a flood of overlapping API calls.
  • UI Resilience: [Editor's note] By emitting FeaturedBooksPaginationLoading, the Cubit signals the UI that a background fetch is occurring. Ensure your BlocBuilder handles this state properly so it continues rendering the old list rather than falling back to a CircularProgressIndicator that replaces the whole screen.