DEV Community

Cover image for Flutter app, Generic blocs for loading and listing
Saad Alkentar
Saad Alkentar

Posted on

Flutter app, Generic blocs for loading and listing

What to expect from this tutorial?

The idea is to create generic reusable blocs, mainly for these two functionalities

  • loading indicator
  • messaging user, toasts
  • list loading, refreshing and pagination

usually, we create a loading indicator for each page alone, like hiding the login button and showing loading indicator in its place, for messaging, we usually listen for certain bloc states, and show their messages, it is also arranged separately for each screen.
instead of repeating this code in every screen, we will build a single bloc that handles those functionalities

Loading bloc

The idea is to build a loader on top of the main screen, this loader overlay the main screen preventing user from playing around while loading. We are also planing to pass messages to this bloc for showing, let's being with the events

part of 'loading_bloc.dart';

@immutable
sealed class LoadingEvent {
  final String? text;

  const LoadingEvent({this.text});
}

class LoadingStartEvent extends LoadingEvent {}
class LoadingStopEvent extends LoadingEvent {}
class LoadingMessageEvent extends LoadingEvent {
  const LoadingMessageEvent({super.text});
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/loading/loading_event.dart

so we have an event to start loading, another one to stop loading of course and a final one to show messages, I guess it is enough for this bloc, you can add more events if necessary.
moving to the states file, it should reflect events file I guess

part of 'loading_bloc.dart';

@immutable
sealed class LoadingState {
  final String? text;

  const LoadingState({this.text});
}

final class LoadingInitial extends LoadingState {}

final class LoadingProgressState extends LoadingState {}
final class LoadingIdleState extends LoadingState {}
final class LoadingMessageState extends LoadingState {
  const LoadingMessageState({super.text});
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/loading/loading_state.dart

similar to the events file, now for the bloc file, it is straightforward, when we get a certain event, we emit its state

import 'dart:async';
import 'dart:io';

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';

part 'loading_event.dart';
part 'loading_state.dart';

class LoadingBloc extends Bloc<LoadingEvent, LoadingState> {


  LoadingBloc() : super(LoadingInitial()) {

    on<LoadingStartEvent>(handleStartEvent);
    on<LoadingStopEvent>(handleStopEvent);
    on<LoadingMessageEvent>(handleMessageEvent);
  }

  FutureOr<void> handleStartEvent(
      LoadingStartEvent event,
      Emitter<LoadingState> emit,
  ) async {
    emit(LoadingProgressState());
  }

  FutureOr<void> handleStopEvent(
      LoadingStopEvent event,
      Emitter<LoadingState> emit,
  ) {
    emit(LoadingIdleState());
  }

  FutureOr<void> handleMessageEvent(
      LoadingMessageEvent event,
      Emitter<LoadingState> emit,
  ) {
    emit(LoadingMessageState(text: event.text));
  }
}

Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/loading/loading_bloc.dart

it cannot be simpler I guess 😅, the trick is in the implementation, we need to implement the loading indicator on top of all screens, so let's edit the main app file in order to do that (we are building upon the last main.dart file from previous tutorials, but you can tune this to your needs 😉)

import 'presentation/blocs/loading/loading_bloc.dart';
import 'package:flutter/cupertino.dart';

...

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {

    final loadingBloc = LoadingBloc();

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      debugShowCheckedModeBanner: false,
      home: MultiBlocProvider(
        providers: [
          BlocProvider(create: (context)=>loadingBloc),
          BlocProvider(create: (context)=>HomeBloc(locator(), locator())),
          BlocProvider(create: (context)=>LoginBloc(locator(), locator())),
        ],
        child: OKToast(
          position: ToastPosition.bottom,
          child: Stack(
            children: [
              MaterialApp.router(
                supportedLocales: context.supportedLocales,
                localizationsDelegates: context.localizationDelegates,
                locale: context.locale,
                routerConfig: appRouter.config(),
              ),


              BlocConsumer<LoadingBloc, LoadingState>(
                listener: (context, state) {
                  if (state is LoadingMessageState) {
                    showToast(state.text ?? "Unexpected error");
                  }
                },
                builder: (context, state) {
                  return (state is LoadingProgressState) ?
                  Container(
                    alignment: Alignment.center,
                    color: Colors.lightBlueAccent.withOpacity(0.1),
                    child: const CupertinoActivityIndicator(),
                  ) :
                  Container();
                },
              ),

            ],
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

lib/main.dart

We are creating the loading bloc, well... it is not a good idea to create it in the build function, so we should move it to the DI later.
In order to show the loader over the main screen, we are putting it in the top of a stack, and to show messages, we are listening for the message state.
the build function can be called multiple times, to avoid creating the loading bloc multiple times, let's move it to the DI config file

import '../presentation/blocs/loading/loading_bloc.dart';

final locator = GetIt.instance;

Future<void> initializeDependencies() async {

  locator.registerSingleton<LoadingBloc>(LoadingBloc());
...
Enter fullscreen mode Exit fullscreen mode

we can remove the creation code from the build function and use the DI object instead

...
- final loadingBloc = LoadingBloc();
...
-           BlocProvider(create: (context)=>loadingBloc),
+           BlocProvider(create: (context)=>locator<LoadingBloc>()),
...
Enter fullscreen mode Exit fullscreen mode

great! let's test it on the login screen bloc, instead of showing the indicator on the login button we will use loadingBloc, so in the login bloc

import 'dart:async';

import 'package:alive_diary_app/domain/repositories/preferences_repository.dart';
import 'package:alive_diary_app/presentation/blocs/loading/loading_bloc.dart';
import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';

import '../../../domain/repositories/remote_repository.dart';
import '../../../utils/data_state.dart';
import '../../../utils/dio_exception_extension.dart';

part 'login_event.dart';
part 'login_state.dart';

class LoginBloc extends Bloc<LoginEvent, LoginState> {

  final RemoteRepository repository;
  final PreferencesRepository preferencesRepository;
  final LoadingBloc loadingBloc;

  LoginBloc(this.repository, this.preferencesRepository, this.loadingBloc) : super(LoginInitial()) {
    on<LoginPressedEvent>(handleLoginEvent);
  }

  FutureOr<void> handleLoginEvent(
      LoginPressedEvent event,
      Emitter<LoginState> emit,
  ) async {
    // emit(LoginLoadingState());
    loadingBloc.add(LoadingStartEvent());

    final response = await repository.login(
      username: event.username,
      password: event.password,
    );

    if (response is DataSuccess) {
      final tokenModel = response.data?.data;
      preferencesRepository.saveLoginModel(tokenModel);

      emit(LoginSuccessState());
    } else if (response is DataFailed) {
      loadingBloc.add(LoadingMessageEvent(text: response.error?.getErrorMessage()));

      // emit(LoginErrorState(message: response.error?.getErrorMessage()));
    }
    loadingBloc.add(LoadingStopEvent());
  }
}

Enter fullscreen mode Exit fullscreen mode

we are passing the loading bloc to the login bloc, and emitting start loading and stop loading when needed, we are also using the message event to show message instead of using the login bloc for that, we need to add the loading bloc as dependency for the login bloc so in the main app file we need to

-          BlocProvider(create: (context)=>LoginBloc(locator(), locator())),
+          BlocProvider(create: (context)=>LoginBloc(locator(), locator(), locator())),

Enter fullscreen mode Exit fullscreen mode

just added locator as the third parameter, since it is provided by our DI already, and that is it

loading message
loading message

it seems to work well, whenever we need to show the loading indicator or a message to the user, we can always use this loading bloc

Listing bloc

listing is a common functionality in most apps, there is always a screen with list of stuff. The idea is to create a list bloc that can used in any listing screen, let's start with the events

part of 'list_bloc.dart';

@immutable
sealed class ListEvent {
  final int? id;
  final String? query;

  const ListEvent({this.id, this.query});
}

class ListGetEvent extends ListEvent {
  const ListGetEvent({super.query});
}

class ListDeleteEvent extends ListEvent {
  const ListDeleteEvent({super.id});
}
class ListRefreshEvent extends ListEvent {
  const ListRefreshEvent({super.query});

}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/list/list_event.dart

we are creating three events, one for getting list data, and another one for refreshing the list, and an event to delete item. we are expecting a query string to filter the list, and an id to know which item to delete. nice and easy, now to the states

part of 'list_bloc.dart';

@immutable
sealed class ListState<T> {
  final List<T>? list;
  final String? errorMessage;
  final bool? noMoreData;

  const ListState({this.list, this.errorMessage, this.noMoreData});
}

final class ListInitialState<T> extends ListState<T> {}

final class ListLoadingState<T> extends ListState<T> {
  const ListLoadingState({super.list});
}

final class ListSuccessState<T> extends ListState<T> {
  const ListSuccessState({super.list, super.noMoreData});
}

final class ListErrorState<T> extends ListState<T> {
  const ListErrorState({super.errorMessage});
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/list/list_state.dart

We have three states, one to show loading indicator, another one to show an error message, and one for loading data successfully.

Bloc logic is kinda complicated let's walk through it ...

  • create aliases for get data function and delete item function
  • pass those functions to the bloc logic
  • the get data event calls get data function
  • delete item event calls delete item function

that is it in a glance, we are

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';

import '../../../domain/models/responses/generic_response.dart';
import '../../../domain/models/responses/list_response.dart';
import '../../../utils/data_state.dart';
import '../../../utils/dio_exception_extension.dart';

part 'list_event.dart';
part 'list_state.dart';

typedef BuildListFuture<T> = Future<DataState<GenericResponse<ListResponse<T>>>> Function(int page, String? query);
typedef BuildDelFuture<T> = Future<DataState<GenericResponse<T>>> Function(int id);

class ListBloc<T> extends Bloc<ListEvent, ListState<T>> {

  int page= 1;
  List<T> list = [];
  bool noMore = false;
  String? query;

  final BuildListFuture<T>? getRequest;
  final BuildDelFuture<String>? deleteRequest;

  ListBloc({
    this.getRequest,
    this.deleteRequest,
  }) : super(ListInitialState<T>()) {
    on<ListGetEvent>(handleGetEvent);
    on<ListRefreshEvent>(handleRefreshEvent);
    on<ListDeleteEvent>(handleDeleteEvent);
  }

  FutureOr<void> handleGetEvent(
      ListGetEvent event,
      Emitter<ListState<T>> emit,
      ) async {

    if (noMore) return;

    emit(ListLoadingState<T>(list: list));

    final response = await getRequest?.call(page, query);
    if (response is DataSuccess) {
      noMore = response?.data?.data?.next == null;

      list.addAll(response?.data?.data?.results ?? []);
      page ++;

      emit(ListSuccessState<T>(
        list: list,
        noMoreData: noMore,
      ));

    } else if (response is DataFailed) {
      emit(ListErrorState(errorMessage: response?.error?.getErrorMessage()));
    }

  }

  FutureOr<void> handleDeleteEvent(
      ListDeleteEvent event,
      Emitter<ListState<T>> emit,
      ) async {

    emit(ListLoadingState<T>(list: list));

    final response = await deleteRequest?.call(event.id ?? 0);
    if (response is DataSuccess) {

      add(ListRefreshEvent());

    } else if (response is DataFailed) {
      emit(ListErrorState(errorMessage: response?.error?.getErrorMessage()));
    }

  }

  FutureOr<void> handleRefreshEvent(
      ListRefreshEvent event,
      Emitter<ListState<T>> emit,
      ) async {

    page = 1;
    list.clear();
    noMore = false;
    query = event.query;
    add(ListGetEvent(query: event.query));

  }
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/blocs/list/list_bloc.dart

Notice that we are using our generic list response to our benefit here 😉, since we know all list requests have this certain response schema, we are using this knowledge to find whether this is the last page based on having the field next there. and getting the data list items from the results field. make sure to tune this based on your list response schema.

With the bloc logic ready, let's test it on an actual page 😁

Listing page

with our listing bloc ready, let's create a list widget that can be easily reused

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';

class AppListWidget<T> extends HookWidget {
  const AppListWidget({
    super.key,
    this.getPageDate,
    this.onRefresh,
    this.onSearch,
    this.buildItem,
    this.list,
    this.noMoreData = true,
    this.isLoading = false,

  });

  final Function(String)? getPageDate;
  final Function(String)? onRefresh;
  final Function(String)? onSearch;
  final Function(T?)? buildItem;
  final List<T>? list;

  final bool noMoreData;
  final bool isLoading;

  @override
  Widget build(BuildContext context) {

    final scrollController = useScrollController();
    final textController = useTextEditingController();


    useEffect(() {

      scrollController.onScrollEndsListener(() {
        getPageDate?.call(textController.text);
      });
      getPageDate?.call(textController.text);

      return null;
    }, []);

    return RefreshIndicator(
      backgroundColor: Colors.white,
      onRefresh: () async {
        onRefresh?.call(textController.text);
      },
      child: Container(
        child: CustomScrollView(
          physics: const AlwaysScrollableScrollPhysics(),
          controller: scrollController,
          slivers: [
            SliverToBoxAdapter(
              child: onSearch == null ? Container() : Container(
                padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
                child: SearchBar(
                  controller: textController,
                  padding: const WidgetStatePropertyAll<EdgeInsets>(
                      EdgeInsets.symmetric(horizontal: 16.0)),
                  onTap: () {

                  },
                  onChanged: (str) {

                    if (str.isEmpty) {
                      onSearch?.call("");
                    } else if (str.length >= 3) {
                      onSearch?.call(str);
                      // getPageDate?.call(str);
                    }
                  },
                  leading: const Icon(Icons.search),
                ),
              ),
            ),

            SliverList(
              delegate: SliverChildBuilderDelegate(
                    (context, index) =>buildItem?.call(list?[index]),
                childCount: list?.length ?? 0,
              ),
            ),
            if (!noMoreData || isLoading)
              const SliverToBoxAdapter(
                child: Padding(
                  padding: EdgeInsets.only(top: 14, bottom: 32),
                  child: CupertinoActivityIndicator(),
                ),
              )
          ],
        ),
      ),
    );
  }
}

extension ScrollControllerExtensions on ScrollController {
  void onScrollEndsListener(
      final void Function() callback, {
        double offset = 0,
      }) {
    addListener(() {
      final maxScroll = position.maxScrollExtent;
      final currentScroll = position.pixels - offset;

      if (currentScroll == maxScroll) {
        callback();
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/widgets/app_list_widget.dart

This widget support auto loading with scrolling out of the box. it uses custom scroll view with slivers. it also show loading indicator while the data is loading.

great, it is time to finish up the screen design

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:oktoast/oktoast.dart';

import '../../blocs/list/list_bloc.dart';
import '../../widgets/app_list_widget.dart';
import '../../../domain/models/entities/memory_model.dart';

@RoutePage()
class MemoriesScreen extends HookWidget {
  const MemoriesScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final listBloc = BlocProvider.of<ListBloc<MemoryModel>>(context);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme
            .of(context)
            .colorScheme
            .inversePrimary,
        title: Text( "Memories"),
      ),
      body: Container(
        child: BlocConsumer<ListBloc<MemoryModel>, ListState<MemoryModel>>(
          listener: (context, state) {
            if (state is ListErrorState) {
              showToast(state.errorMessage ?? "error");
            }
          },
          builder: (context, state) {
            return AppListWidget<MemoryModel>(
              list: state.list,
              isLoading: state is ListLoadingState,
              getPageDate: (str) => listBloc.add(ListGetEvent()),
              buildItem: (item) => Container(
                alignment: Alignment.center,
                child: Text(item?.title ?? ""),
                height: 150,
              ),
              noMoreData: state.noMoreData ?? true,
              onRefresh: (str) => listBloc.add(ListRefreshEvent()),
            );
          },
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

lib/presentation/screens/memories/memories_screen.dart

it uses the AppListWidget with the generic ListBloc, we should change the buildItem to build the actual list item, all the rest can be reused as is in most list screens 🤓.

of course for it to work, we should provide the list bloc in the main app file

...
      home: MultiBlocProvider(
        providers: [
          BlocProvider(create: (context)=>locator<LoadingBloc>()),
          BlocProvider(create: (context)=>HomeBloc(locator(), locator())),
          BlocProvider(create: (context)=>LoginBloc(locator(), locator(), locator())),
          BlocProvider(create: (context) => ListBloc(
              getRequest: (page, query)=>locator<RemoteRepository>().memoriesList(
                page: page,
              ),
              // deleteRequest: (id)=>locator<RemoteRepository>().memoriesDelete(
              //   id: id,
              // )
          )),
        ],
...
Enter fullscreen mode Exit fullscreen mode

lib/main.dart

we are providing the list bloc with the get and delete function (if we need to). we are using the remote repository to get the data. with this setup ready, we should be able to load the memories list in the memories screen

loading more
loading load more

That is it! I know it is kinda complicated, so ask me if you need more explanation for the generic list bloc. I believe the code is kinda clear for you all to read and understand. yet, I'm always here

We will continue with the app design on next articles, so

Stay tuned 😎

Top comments (0)