bloc_infinity_list 0.0.8 copy "bloc_infinity_list: ^0.0.8" to clipboard
bloc_infinity_list: ^0.0.8 copied to clipboard

Infinite scrolling ListView with BLoC integration for Flutter applications.

example/lib/main.dart

import 'dart:async';

import 'package:bloc_infinity_list/bloc_infinity_list.dart';
import 'package:bloc_infinity_list/infinite_list_bloc/infinite_list_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// A simple data class representing an item in the list.
class ListItem {
  static int _staticId = 0;

  final int id;
  final String name;
  final String description;

  ListItem({required this.name, required this.description}) : id = ++_staticId;

  /// Resets the static ID counter. Useful for testing.
  static void resetIdCounter() {
    _staticId = 0;
  }
}

/// A custom BLoC that extends [InfiniteListBloc] to fetch [ListItem]s.
class MyCustomBloc extends InfiniteListBloc<ListItem> {
  /// Constructor accepts an optional list of initial items.
  MyCustomBloc({super.initialItems, super.limitFetch});

  @override
  Future<List<ListItem>> fetchItems({
    required int limit,
    required int offset,
  }) async {
    // Simulate network delay
    await Future.delayed(const Duration(seconds: 1));

    // Simulate end of data
    if (offset >= 50) {
      return [];
    }

    // Generate dummy data
    return List.generate(
      limit,
      (index) => ListItem(
        name: 'Item ${offset + index + 1}',
        description: 'Description for item ${offset + index + 1}',
      ),
    );
  }
}

void main() {
  runApp(const MyApp());
}

/// The root widget of the application.
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Infinite ListView Example',
      theme: ThemeData(
        primarySwatch: Colors.purple,
        scaffoldBackgroundColor: const Color(0xFFF5F5F5),
        textTheme: const TextTheme(
          bodyMedium: TextStyle(fontSize: 16.0),
          titleLarge: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
        ),
      ),
      home: const HomePage(),
    );
  }
}

/// The home page that contains navigation to the four examples.
class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

/// State class for [HomePage].
class _HomePageState extends State<HomePage> {
  // Set Automatic (shrinkWrap = true) as default index = 0
  int _selectedIndex = 0;

  // We now have 4 pages:
  final List<Widget> _pages = [
    // Automatic with shrinkWrap = true
    const AutomaticInfiniteListPage(),
    // Automatic with shrinkWrap = false (NEW PAGE)
    const AutomaticInfiniteListPageNoShrinkWrap(),
    // Manual infinite list
    const ManualInfiniteListPage(),
    // Manual infinite list with initial items
    const ManualInfiniteListPageWithInitialItems(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Infinite ListView Example'),
      ),
      body: _pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: _selectedIndex,
        selectedItemColor: Colors.purpleAccent,
        unselectedItemColor: Colors.deepPurple,
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.autorenew),
            label: 'Auto ShrinkWrap',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.vertical_align_bottom),
            label: 'Auto Full',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.touch_app),
            label: 'Manual',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.list_alt),
            label: 'Default Manual',
          ),
        ],
        onTap: (index) {
          setState(() {
            _selectedIndex = index;
          });
        },
      ),
    );
  }
}

/// A page demonstrating the automatic infinite list with shrinkWrap enabled.
class AutomaticInfiniteListPage extends StatefulWidget {
  const AutomaticInfiniteListPage({super.key});

  @override
  State<AutomaticInfiniteListPage> createState() =>
      _AutomaticInfiniteListPageState();
}

class _AutomaticInfiniteListPageState extends State<AutomaticInfiniteListPage> {
  late final MyCustomBloc _bloc;

  @override
  void initState() {
    super.initState();
    _bloc = MyCustomBloc();
  }

  @override
  void dispose() {
    _bloc.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider<MyCustomBloc>(
      create: (_) => _bloc,
      child: InfiniteListView<ListItem>.automatic(
        bloc: _bloc,
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        backgroundColor: Colors.white,
        padding: const EdgeInsets.all(16.0),
        borderRadius: BorderRadius.circular(12.0),
        borderColor: Colors.grey.shade300,
        borderWidth: 1.0,
        boxShadow: [
          BoxShadow(
            color: Colors.grey.shade200,
            blurRadius: 6.0,
            spreadRadius: 2.0,
            offset: const Offset(0, 3),
          ),
        ],
        itemBuilder: _buildListItem,
        dividerWidget: const SizedBox(height: 0),
        loadingWidget: _buildLoadingWidget,
        errorWidget: _buildErrorWidget,
        emptyWidget: _buildEmptyWidget,
        noMoreItemWidget: _buildNoMoreItemWidget,
      ),
    );
  }

  Widget _buildListItem(BuildContext context, ListItem item) {
    return Card(
      elevation: 2.0,
      margin: const EdgeInsets.symmetric(vertical: 4.0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10.0),
      ),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: Theme.of(context).primaryColor,
          child: Text(
            item.id.toString(),
            style: const TextStyle(color: Colors.white),
          ),
        ),
        title: Text(
          item.name,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        subtitle: Text(
          item.description,
          style: Theme.of(context).textTheme.bodyMedium,
        ),
        trailing: const Icon(Icons.arrow_forward_ios),
        onTap: () {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Tapped on ${item.name}')),
          );
        },
      ),
    );
  }

  Widget _buildLoadingWidget(BuildContext context) => Padding(
        padding: const EdgeInsets.all(12.0),
        child: Center(
          child: CircularProgressIndicator(
            valueColor:
                AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
          ),
        ),
      );

  Widget _buildErrorWidget(BuildContext context, String error) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, color: Colors.red.shade300, size: 48),
            const SizedBox(height: 8),
            Text(
              'Something went wrong!',
              style: TextStyle(
                color: Colors.red.shade300,
                fontSize: 18,
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton.icon(
              onPressed: () {
                _bloc.add(LoadItemsEvent());
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Theme.of(context).primaryColor,
              ),
            ),
          ],
        ),
      );

  Widget _buildEmptyWidget(BuildContext context) => Center(
        child: Text(
          'No items available',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 18,
          ),
        ),
      );

  Widget _buildNoMoreItemWidget(BuildContext context) => Center(
        child: Text(
          'You have reached the end!',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 16,
          ),
        ),
      );
}

class AutomaticInfiniteListPageNoShrinkWrap extends StatefulWidget {
  const AutomaticInfiniteListPageNoShrinkWrap({super.key});

  @override
  State<AutomaticInfiniteListPageNoShrinkWrap> createState() =>
      _AutomaticInfiniteListPageNoShrinkWrapState();
}

class _AutomaticInfiniteListPageNoShrinkWrapState
    extends State<AutomaticInfiniteListPageNoShrinkWrap> {
  late final MyCustomBloc _bloc;

  @override
  void initState() {
    super.initState();
    _bloc = MyCustomBloc();
  }

  @override
  void dispose() {
    _bloc.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider<MyCustomBloc>(
      create: (_) => _bloc,
      child: InfiniteListView<ListItem>.automatic(
        bloc: _bloc,
        shrinkWrap: false,
        physics: const BouncingScrollPhysics(),
        backgroundColor: Colors.white,
        padding: const EdgeInsets.all(16.0),
        borderRadius: BorderRadius.circular(12.0),
        borderColor: Colors.grey.shade300,
        borderWidth: 1.0,
        boxShadow: [
          BoxShadow(
            color: Colors.grey.shade200,
            blurRadius: 6.0,
            spreadRadius: 2.0,
            offset: const Offset(0, 3),
          ),
        ],
        itemBuilder: _buildListItem,
        dividerWidget: const Divider(thickness: 2),
        showLastDivider: () => true,
        loadingWidget: _buildLoadingWidget,
        errorWidget: _buildErrorWidget,
        emptyWidget: _buildEmptyWidget,
        noMoreItemWidget: _buildNoMoreItemWidget,
      ),
    );
  }

  Widget _buildListItem(BuildContext context, ListItem item) {
    return Card(
      elevation: 2.0,
      margin: const EdgeInsets.symmetric(vertical: 4.0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10.0),
      ),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: Theme.of(context).primaryColor,
          child: Text(
            item.id.toString(),
            style: const TextStyle(color: Colors.white),
          ),
        ),
        title: Text(
          item.name,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        subtitle: Text(
          item.description,
          style: Theme.of(context).textTheme.bodyMedium,
        ),
        trailing: const Icon(Icons.arrow_forward_ios),
        onTap: () {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Tapped on ${item.name}')),
          );
        },
      ),
    );
  }

  Widget _buildLoadingWidget(BuildContext context) => Padding(
        padding: const EdgeInsets.all(12.0),
        child: Center(
          child: CircularProgressIndicator(
            valueColor:
                AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
          ),
        ),
      );

  Widget _buildErrorWidget(BuildContext context, String error) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, color: Colors.red.shade300, size: 48),
            const SizedBox(height: 8),
            Text(
              'Something went wrong!',
              style: TextStyle(
                color: Colors.red.shade300,
                fontSize: 18,
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton.icon(
              onPressed: () {
                _bloc.add(LoadItemsEvent());
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Theme.of(context).primaryColor,
              ),
            ),
          ],
        ),
      );

  Widget _buildEmptyWidget(BuildContext context) => Center(
        child: Text(
          'No items available',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 18,
          ),
        ),
      );

  Widget _buildNoMoreItemWidget(BuildContext context) => Center(
        child: Text(
          'You have reached the end!',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 16,
          ),
        ),
      );
}

/// A page demonstrating the manual infinite list with a "Load More" button.
class ManualInfiniteListPage extends StatefulWidget {
  const ManualInfiniteListPage({super.key});

  @override
  State<ManualInfiniteListPage> createState() => _ManualInfiniteListPageState();
}

class _ManualInfiniteListPageState extends State<ManualInfiniteListPage> {
  late final MyCustomBloc _bloc;

  @override
  void initState() {
    super.initState();
    // Initialize the bloc without initial items
    _bloc = MyCustomBloc();
  }

  @override
  void dispose() {
    _bloc.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocProvider<MyCustomBloc>(
      create: (_) => _bloc,
      child: InfiniteListView<ListItem>.manual(
        bloc: _bloc,
        backgroundColor: Colors.white,
        padding: const EdgeInsets.all(16.0),
        borderRadius: BorderRadius.circular(12.0),
        borderColor: Colors.grey.shade300,
        borderWidth: 1.0,
        boxShadow: [
          BoxShadow(
            color: Colors.grey.shade200,
            blurRadius: 6.0,
            spreadRadius: 2.0,
            offset: const Offset(0, 3),
          ),
        ],
        physics: const BouncingScrollPhysics(),
        itemBuilder: _buildListItem,
        loadMoreButtonBuilder: _buildLoadMoreButton,
        dividerWidget: const Divider(
          height: 2,
          thickness: 1,
          indent: 20,
        ),
        showLastDivider: () => _bloc.state is! NoMoreItemsState,
        loadingWidget: _buildLoadingWidget,
        errorWidget: _buildErrorWidget,
        emptyWidget: _buildEmptyWidget,
        noMoreItemWidget: _buildNoMoreItemWidget,
      ),
    );
  }

  Widget _buildListItem(BuildContext context, ListItem item) {
    return Card(
      elevation: 2.0,
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10.0),
      ),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: Theme.of(context).primaryColor,
          child: Text(
            item.id.toString(),
            style: const TextStyle(color: Colors.white),
          ),
        ),
        title: Text(
          item.name,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        subtitle: Text(
          item.description,
          style: Theme.of(context).textTheme.bodyMedium,
        ),
        trailing: const Icon(Icons.arrow_forward_ios),
        onTap: () {
          // Handle item tap
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Tapped on ${item.name}')),
          );
        },
      ),
    );
  }

  Widget _buildLoadMoreButton(BuildContext context) {
    final state = _bloc.state;
    final isLoading = state is LoadingState<ListItem>;

    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: ElevatedButton(
        key: const Key('loadMoreButton'), // Assigning a unique key here
        onPressed: isLoading
            ? null
            : () {
                _bloc.add(LoadMoreItemsEvent());
              },
        style: ElevatedButton.styleFrom(
          minimumSize: const Size(double.infinity, 50),
          backgroundColor: Theme.of(context).primaryColor,
        ),
        child: isLoading
            ? const SizedBox(
                width: 24,
                height: 24,
                child: CircularProgressIndicator(
                  valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
                  strokeWidth: 2.0,
                ),
              )
            : const Text(
                'Load More',
                style: TextStyle(fontSize: 18, color: Colors.white),
              ),
      ),
    );
  }

  Widget _buildLoadingWidget(BuildContext context) => Padding(
        padding: const EdgeInsets.all(16.0),
        child: Center(
          child: CircularProgressIndicator(
            valueColor:
                AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
          ),
        ),
      );

  Widget _buildErrorWidget(BuildContext context, String error) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, color: Colors.red.shade300, size: 48),
            const SizedBox(height: 8),
            Text(
              'Something went wrong!',
              style: TextStyle(
                color: Colors.red.shade300,
                fontSize: 18,
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton.icon(
              onPressed: () {
                _bloc.add(LoadItemsEvent());
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Theme.of(context).primaryColor,
              ),
            ),
          ],
        ),
      );

  Widget _buildEmptyWidget(BuildContext context) => Center(
        child: Text(
          'No items available',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 18,
          ),
        ),
      );

  Widget _buildNoMoreItemWidget(BuildContext context) => Center(
        child: Text(
          'You have reached the end!',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 16,
          ),
        ),
      );
}

/// A second manual infinite list page demonstrating another manual list with initial items.
/// **This page is updated to dynamically grow in height and can be embedded within another scrollable widget.**
class ManualInfiniteListPageWithInitialItems extends StatefulWidget {
  const ManualInfiniteListPageWithInitialItems({super.key});

  @override
  State<ManualInfiniteListPageWithInitialItems> createState() =>
      _ManualInfiniteListPageWithInitialItemsState();
}

class _ManualInfiniteListPageWithInitialItemsState
    extends State<ManualInfiniteListPageWithInitialItems> {
  late final MyCustomBloc _bloc;

  @override
  void initState() {
    super.initState();
    // Define 5 initial items
    final initialItems = List.generate(
      5,
      (index) => ListItem(
        name: 'Secondary Preloaded Item ${index + 1}',
        description: 'Description for secondary preloaded item ${index + 1}',
      ),
    );
    // Initialize the bloc with initial items
    _bloc = MyCustomBloc(initialItems: initialItems, limitFetch: 5);
  }

  @override
  void dispose() {
    _bloc.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // **Embedding within SingleChildScrollView and Column for dynamic height**
    return SingleChildScrollView(
      child: Column(
        children: [
          // Other widgets above the InfiniteListView
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text(
              'Manual Infinite List with Initial Items',
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
          // The updated InfiniteListView.manual with shrinkWrap enabled
          BlocProvider<MyCustomBloc>(
            create: (_) => _bloc,
            child: InfiniteListView<ListItem>.manual(
              bloc: _bloc,
              shrinkWrap: true,
              // Enable shrink wrapping
              backgroundColor: Colors.white,
              padding: const EdgeInsets.all(16.0),
              borderRadius: BorderRadius.circular(12.0),
              borderColor: Colors.grey.shade300,
              borderWidth: 1.0,
              boxShadow: [
                BoxShadow(
                  color: Colors.grey.shade200,
                  blurRadius: 6.0,
                  spreadRadius: 2.0,
                  offset: const Offset(0, 3),
                ),
              ],
              itemBuilder: _buildListItem,
              loadMoreButtonBuilder: _buildLoadMoreButton,
              dividerWidget: const SizedBox(height: 0),
              loadingWidget: _buildLoadingWidget,
              errorWidget: _buildErrorWidget,
              emptyWidget: _buildEmptyWidget,
              noMoreItemWidget: _buildNoMoreItemWidget,
            ),
          ),
          // Other widgets below the InfiniteListView
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: Text(
              'Footer Widget',
              style: Theme.of(context).textTheme.titleLarge,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildListItem(BuildContext context, ListItem item) {
    return Card(
      elevation: 2.0,
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10.0),
      ),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: Colors.deepPurple,
          child: Text(
            item.id.toString(),
            style: const TextStyle(color: Colors.white),
          ),
        ),
        title: Text(
          item.name,
          style: Theme.of(context).textTheme.titleLarge,
        ),
        subtitle: Text(
          item.description,
          style: Theme.of(context).textTheme.bodyMedium,
        ),
        trailing: const Icon(Icons.arrow_forward_ios),
        onTap: () {
          // Handle item tap
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text('Tapped on ${item.name}')),
          );
        },
      ),
    );
  }

  Widget _buildLoadMoreButton(BuildContext context) {
    final state = _bloc.state;
    final isLoading = state is LoadingState<ListItem>;

    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: ElevatedButton(
        key: const Key('loadMoreButton'), // Assigning a unique key here
        onPressed: isLoading
            ? null
            : () {
                _bloc.add(LoadMoreItemsEvent());
              },
        style: ElevatedButton.styleFrom(
          minimumSize: const Size(double.infinity, 50),
          backgroundColor: Colors.deepPurple,
        ),
        child: isLoading
            ? const SizedBox(
                width: 24,
                height: 24,
                child: CircularProgressIndicator(
                  valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
                  strokeWidth: 2.0,
                ),
              )
            : const Text(
                'Load More',
                style: TextStyle(fontSize: 18, color: Colors.white),
              ),
      ),
    );
  }

  Widget _buildLoadingWidget(BuildContext context) => const Padding(
        padding: EdgeInsets.all(16.0),
        child: Center(
          child: CircularProgressIndicator(),
        ),
      );

  Widget _buildErrorWidget(BuildContext context, String error) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.error_outline, color: Colors.red.shade300, size: 48),
            const SizedBox(height: 8),
            Text(
              'Something went wrong!',
              style: TextStyle(
                color: Colors.red.shade300,
                fontSize: 18,
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton.icon(
              onPressed: () {
                _bloc.add(LoadItemsEvent());
              },
              icon: const Icon(Icons.refresh),
              label: const Text('Retry'),
              style: ElevatedButton.styleFrom(
                backgroundColor: Colors.deepPurple,
              ),
            ),
          ],
        ),
      );

  Widget _buildEmptyWidget(BuildContext context) => Center(
        child: Text(
          'No items available',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 18,
          ),
        ),
      );

  Widget _buildNoMoreItemWidget(BuildContext context) => Center(
        child: Text(
          'No more items',
          style: TextStyle(
            color: Colors.grey.shade600,
            fontSize: 16,
          ),
        ),
      );
}