base_flutter_bloc 0.0.5
base_flutter_bloc: ^0.0.5 copied to clipboard
Simplify BLoC usage: unified 4-state machine, auto loading/error UI, flushbar notifications, retry, pagination, debounce/throttle, safeEmit and more — all in one lightweight package.
base_flutter_bloc #
Simplify BLoC usage: unified 4-state machine, auto loading/error UI, flushbar notifications, retry, pagination, debounce/throttle, safeEmit and more — all in one lightweight package.
The problem this package solves #
A typical BLoC screen without this package looks like this:
// ❌ Before — repeated on EVERY screen
BlocConsumer<UserBloc, UserState>(
listener: (context, state) {
if (state is UserErrorState) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is UserLoadingState) {
return const Center(child: CircularProgressIndicator());
}
if (state is UserErrorState) {
return Center(child: Text(state.message));
}
if (state is UserSuccessState) {
return Text(state.user.name);
}
return const SizedBox.shrink();
},
);
With base_flutter_bloc, the same screen becomes:
// ✅ After — clean, no boilerplate
BaseBlocConsumer<UserBloc, User>(
builder: (context, user) => Text(user.name),
);
Loading spinner, error widget, and error notification — all handled automatically.
Features #
- 🧱
BaseState<T>— a unified 4-state machine (Initial,Loading,Success,Error) that works for any data type - ⚡
BaseBlocBuilder— builds UI for all four states with a single required parameter - 🔔
BaseBlocConsumer— builds UI and shows notifications (flushbar) on error/success automatically - 👂
BaseBlocListener— listens to state changes and shows notifications without rebuilding the widget tree - 🛠
BaseBloc— base class withexecuteWithErrorHandling(try/catch elimination) and built-in retry with delay - 🟦
BaseCubit— same utilities asBaseBlocbut forCubit-based state management, includingsafeEmit() - 🔍
BaseBlocObserver— plug-and-play debug logger + single hook for crash-reporting services - ⏱
debounce/throttle— ready-made event transformers for search fields and rapid-tap protection - 📄
BasePaginationBloc— full pagination lifecycle (first load, load more, refresh) in one abstract class - 🔗
BuildContextextensions —addEvent,watchSuccessData,watchIsLoading, and more - 🎨
BaseFlutterBlocConfig— global configuration to replace the default error widget and notification with your own implementations
Getting started #
Add the dependency to your pubspec.yaml:
dependencies:
base_flutter_bloc: ^<latest_version>
Then run:
flutter pub get
Usage #
1. Define your BLoC #
Extend BaseBloc and emit BaseState<T> subclasses. Use executeWithErrorHandling to avoid writing try/catch every time:
class UserBloc extends BaseBloc<UserEvent, BaseState<User>> {
final UserRepository _repository;
UserBloc(this._repository) : super(InitialState()) {
on<FetchUserEvent>(_onFetchUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<BaseState<User>> emit,
) async {
emit(LoadingState());
await executeWithErrorHandling(
action: () => _repository.getUser(event.id),
onSuccess: (user) => emit(SuccessState(user)),
onError: (message) => emit(ErrorState(message)),
);
}
}
No try/catch on every handler — executeWithErrorHandling catches exceptions and routes them to onError automatically.
2. Build your screen #
BaseBlocBuilder — UI only
Use it when you only need to render different widgets per state, without side effects.
BaseBlocBuilder<UserBloc, User>(
// Required: builds the widget when data is loaded
builder: (context, user) => UserCard(user: user),
// Optional overrides (all have sensible defaults):
loadingBuilder: (context) => const MyCustomSkeletonLoader(),
errorBuilder: (context, message) => MyErrorBanner(message: message),
initialBuilder: (context) => const WelcomePlaceholder(),
onRefresh: () => context.read<UserBloc>().add(FetchUserEvent()),
);
| State | Default behaviour | Override |
|---|---|---|
InitialState |
SizedBox.shrink() |
initialBuilder |
LoadingState |
CircularProgressIndicator.adaptive() |
loadingBuilder |
ErrorState |
DefaultErrorWidget with retry button |
errorBuilder |
SuccessState |
— | builder (required) |
BaseBlocConsumer — UI + notifications
Use it when you need to both render UI and react to state changes (e.g. show a notification on error).
BaseBlocConsumer<UserBloc, User>(
builder: (context, user) => UserCard(user: user),
// By default, shows an error flushbar automatically on ErrorState.
// Set to false to suppress it:
showDefaultErrorFlushbar: true,
// Optional: show a success notification when data loads:
showDefaultSuccessFlushbar: false,
// Optional: handle errors manually instead of using the default flushbar:
onError: (context, message) => showDialog(
context: context,
builder: (_) => AlertDialog(content: Text(message)),
),
onRefresh: () => context.read<UserBloc>().add(FetchUserEvent()),
);
BaseBlocListener — notifications only
Use it when the widget tree doesn't need to rebuild but you want to react to state transitions (e.g. navigate after success).
BaseBlocListener<AuthBloc, AuthData>(
showDefaultErrorFlushbar: true,
onSuccess: (context, data) => Navigator.pushReplacementNamed(context, '/home'),
child: const LoginForm(),
);
3. Emit states from your BLoC #
// Show a loading spinner
emit(LoadingState());
// Pass your data to the UI
emit(SuccessState(user));
// Show the error widget + flushbar notification
emit(ErrorState('Failed to load user'));
// Show the error widget WITHOUT a flushbar notification
emit(ErrorState('Not critical error', showFlushbar: false));
// Attach the raw exception for debugging
emit(ErrorState('Something went wrong', error: exception));
4. Customize globally with BaseFlutterBlocConfig #
Wrap your app (or any subtree) with BaseFlutterBlocConfig to replace the default error widget and/or notification across the entire app — without touching individual screens.
void main() {
runApp(
BaseFlutterBlocConfig(
// Replace DefaultErrorWidget with your own design
errorWidgetBuilder: (context, message, onRetry) => MyErrorWidget(
message: message,
onRetry: onRetry,
),
// Replace the flushbar with a SnackBar, toast, etc.
showFlushBarCallback: ({
required context,
message,
title,
isError = false,
messageColor,
titleColor,
durationSeconds = 3,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message ?? ''),
backgroundColor: isError ? Colors.red : Colors.green,
),
);
},
child: const MyApp(),
),
);
}
If BaseFlutterBlocConfig is not provided, the package uses its built-in defaults — so existing code requires zero changes.
5. Use BaseCubit for simpler state management #
When you don't need traceable events, extend BaseCubit instead of BaseBloc. It has the same executeWithErrorHandling utility plus safeEmit — which guards against emitting after the cubit is closed.
class UserCubit extends BaseCubit<BaseState<User>> {
final UserRepository _repo;
UserCubit(this._repo) : super(InitialState());
Future<void> fetchUser(String id) async {
safeEmit(LoadingState()); // safe even if user navigates away
await executeWithErrorHandling(
action: () => _repo.getUser(id),
onSuccess: (user) => safeEmit(SuccessState(user)),
onError: (msg) => safeEmit(ErrorState(msg)),
);
}
}
BaseBlocBuilder, BaseBlocConsumer, and BaseBlocListener all work with BaseCubit exactly the same way as with BaseBloc.
6. Automatic retries in executeWithErrorHandling #
Both BaseBloc and BaseCubit support retrying a failed request.
await executeWithErrorHandling(
action: () => _repo.getUser(event.id),
onSuccess: (user) => emit(SuccessState(user)),
onError: (msg) => emit(ErrorState(msg)),
retries: 3, // retry up to 3 times
retryDelay: const Duration(seconds: 2), // wait 2 s between attempts
);
If all attempts fail, onError is called once with the last exception message.
7. Set up BaseBlocObserver #
Register once in main() to get structured debug logs for every state transition and a global hook for crash-reporting services.
void main() {
Bloc.observer = BaseBlocObserver(
// Forward errors to your crash reporter (e.g. Firebase Crashlytics):
onErrorCallback: (bloc, error, stackTrace) {
FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
logTransitions: true, // log every state change (debug mode only)
logEvents: true, // log every incoming event
logCreations: false, // log when blocs are instantiated
logClosings: false, // log when blocs are disposed
);
runApp(
BaseFlutterBlocConfig(child: const MyApp()),
);
}
All console output is gated behind kDebugMode — zero noise in release builds.
8. Debounce and throttle event transformers #
Pass a transformer to Bloc.on<E>() to control how quickly events are processed.
debounce — ideal for search fields
Ignores events until the user stops firing them for the given duration. Only the last event in a burst is processed.
class SearchBloc extends BaseBloc<SearchEvent, BaseState<List<Product>>> {
SearchBloc(this._repo) : super(InitialState()) {
on<SearchQueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 400)),
);
}
Future<void> _onQueryChanged(
SearchQueryChanged event,
Emitter<BaseState<List<Product>>> emit,
) async {
emit(LoadingState());
await executeWithErrorHandling(
action: () => _repo.search(event.query),
onSuccess: (results) => emit(SuccessState(results)),
onError: (msg) => emit(ErrorState(msg)),
);
}
}
throttle — ideal for buttons
Forwards the first event immediately, then ignores subsequent events for the given duration. Prevents duplicate network requests from accidental double-taps.
on<PlaceOrderEvent>(
_onPlaceOrder,
transformer: throttle(const Duration(seconds: 2)),
);
9. Paginated lists with BasePaginationBloc #
Extend BasePaginationBloc<T> and implement the single required method fetchPage. Everything else — state management, first load, load more, and refresh — is handled for you.
class ProductsBloc extends BasePaginationBloc<Product> {
final ProductRepository _repo;
ProductsBloc(this._repo);
@override
int get pageSize => 20; // optional, default is 20
@override
Future<List<Product>> fetchPage(int page, int pageSize) =>
_repo.getProducts(page: page, limit: pageSize);
}
In the UI:
// Trigger the first load (e.g. in initState):
context.read<ProductsBloc>().loadFirstPage();
// Load the next page when the user scrolls to the bottom:
context.read<ProductsBloc>().loadNextPage();
// Pull-to-refresh:
context.read<ProductsBloc>().refresh();
Building the list:
BlocBuilder<ProductsBloc, PaginationState<Product>>(
builder: (context, state) {
if (state.isInitial || state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
if (state.isFirstPageError) {
return DefaultErrorWidget(message: state.error!);
}
return ListView.builder(
itemCount: state.items.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.items.length) {
// Bottom spinner while loading the next page
return const Center(child: CircularProgressIndicator.adaptive());
}
return ProductTile(product: state.items[index]);
},
);
},
)
10. BuildContext extensions #
Convenience helpers that reduce common one-liners even further.
// Dispatch an event without context.read<B>().add(...):
context.addEvent<UserBloc, UserEvent>(FetchUserEvent(id: '42'));
// Read data from SuccessState without casting:
final user = context.successData<UserBloc, User>(); // null if not SuccessState
// Reactive helpers that rebuild the widget on state change:
final user = context.watchSuccessData<UserBloc, User>();
final loading = context.watchIsLoading<UserBloc, User>();
// Non-reactive reads (use inside callbacks, not build()):
final hasErr = context.hasError<UserBloc, User>();
final errMsg = context.errorMessage<UserBloc, User>();
API reference #
BaseState<T> #
| Class | Description |
|---|---|
InitialState<T> |
The bloc has not started loading yet |
LoadingState<T> |
An async operation is in progress |
SuccessState<T>(data) |
Data loaded successfully, holds T data |
ErrorState<T>(message) |
An error occurred, holds message, optional error, and showFlushbar flag |
BaseBloc<E, S> #
Extends flutter_bloc's Bloc. Adds:
Future<void> executeWithErrorHandling<T>({
required Future<T> Function() action,
required void Function(T data) onSuccess,
void Function(String error)? onError,
int retries = 0,
Duration retryDelay = const Duration(seconds: 1),
})
BaseCubit<S> #
Extends flutter_bloc's Cubit. Adds the same executeWithErrorHandling as BaseBloc, plus:
/// Emits [state] only if the cubit is not yet closed.
void safeEmit(S state)
BaseBlocObserver #
| Parameter | Type | Default | Description |
|---|---|---|---|
onErrorCallback |
void Function(BlocBase, Object, StackTrace)? |
— | Called on every bloc/cubit error |
logTransitions |
bool |
true |
Log state changes in debug mode |
logEvents |
bool |
true |
Log incoming events in debug mode |
logCreations |
bool |
false |
Log when blocs are created |
logClosings |
bool |
false |
Log when blocs are closed |
debounce<E>(Duration) / throttle<E>(Duration) #
Both return an EventTransformer<E> for use with Bloc.on<E>(handler, transformer: ...).
debounce— emits only after [duration] of silence; discards intermediate events.throttle— emits the first event immediately, ignores the rest for [duration].
PaginationState<T> #
| Field | Type | Description |
|---|---|---|
items |
List<T> |
All loaded items across all pages |
page |
int |
Last successfully loaded page index (0-based) |
hasMore |
bool |
Whether there are more pages to load |
isLoading |
bool |
true during the first page load |
isLoadingMore |
bool |
true while loading a subsequent page |
error |
String? |
Error message or null |
isInitial |
bool |
true before any load is triggered |
isFirstPageError |
bool |
true when error occurred and no items were loaded |
BasePaginationBloc<T> #
Extend and implement one method:
Future<List<T>> fetchPage(int page, int pageSize);
Trigger actions via: loadFirstPage(), loadNextPage(), refresh().
Optionally override int get pageSize => 20;.
BuildContext extensions #
| Method | Returns | Description |
|---|---|---|
addEvent<B, E>(event) |
void |
Shorthand for read<B>().add(event) |
currentState<B, S>() |
S |
Current state (non-reactive) |
isLoading<B, T>() |
bool |
true if state is LoadingState (non-reactive) |
hasError<B, T>() |
bool |
true if state is ErrorState (non-reactive) |
errorMessage<B, T>() |
String? |
Error message or null (non-reactive) |
successData<B, T>() |
T? |
Data from SuccessState or null (non-reactive) |
watchSuccessData<B, T>() |
T? |
Reactive — rebuilds widget on change |
watchIsLoading<B, T>() |
bool |
Reactive — rebuilds widget on change |
BaseBlocBuilder<B, T> #
| Parameter | Type | Description |
|---|---|---|
builder |
Widget Function(context, T data) |
Required. Builds UI for SuccessState |
loadingBuilder |
Widget Function(context)? |
Custom loading widget |
errorBuilder |
Widget Function(context, String)? |
Custom error widget |
initialBuilder |
Widget Function(context)? |
Custom initial widget |
onRefresh |
VoidCallback? |
Passed to the default error widget as "retry" |
BaseBlocConsumer<B, T> #
All parameters from BaseBlocBuilder, plus:
| Parameter | Type | Default | Description |
|---|---|---|---|
showDefaultErrorFlushbar |
bool |
true |
Show notification automatically on ErrorState |
showDefaultSuccessFlushbar |
bool |
false |
Show notification automatically on SuccessState |
onError |
void Function(context, String)? |
— | Override error handling |
onSuccess |
void Function(context, T)? |
— | Override success handling |
onMessage |
void Function(context, String)? |
— | Custom message handling |
BaseBlocListener<B, T> #
| Parameter | Type | Default | Description |
|---|---|---|---|
child |
Widget |
Required | The widget subtree to wrap |
showDefaultErrorFlushbar |
bool |
true |
Show notification on ErrorState |
showDefaultSuccessFlushbar |
bool |
false |
Show notification on SuccessState |
onError |
void Function(context, String)? |
— | Override error handling |
onSuccess |
void Function(context, T)? |
— | Override success handling |