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});
}
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});
}
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));
}
}
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();
},
),
],
),
),
),
);
}
}
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());
...
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>()),
...
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());
}
}
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())),
just added locator as the third parameter, since it is provided by our DI already, and that is it
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});
}
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});
}
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 anddelete item
function - pass those functions to the bloc logic
- the
get data
event callsget data
function -
delete item
event callsdelete 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));
}
}
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();
}
});
}
}
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()),
);
},
),
),
);
}
}
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,
// )
)),
],
...
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 |
---|---|
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)