blocx_core 0.8.2
blocx_core: ^0.8.2 copied to clipboard
Composable BLoC mixins and use-cases for lists & forms (Dart-only core).
blocx_core
Build production-ready list and form BLoCs with pagination, search, refresh, validation, and selection built in.
Pure Dart • Composable Mixins • flutter_bloc Compatible
Why BlocX? #
Most Flutter applications eventually build the same BLoCs over and over:
- Infinite scrolling lists
- Searchable lists
- Pull-to-refresh
- Item selection
- Expandable rows
- Form validation
- Async uniqueness checks
- Error handling
BlocX extracts these patterns into reusable, composable building blocks so you can focus on business logic instead of infrastructure.
Before vs After #
Traditional flutter_bloc #
class TodosBloc extends Bloc<TodosEvent, TodosState> {
// 200+ lines of infrastructure code
}
BlocX #
class TodosBloc extends BlocxListBloc<Todo, void>
with
BlocxCollectionInfiniteMixin<Todo, void>,
BlocxCollectionSearchableMixin<Todo, void>,
BlocxCollectionRefreshableMixin<Todo, void>,
BlocxCollectionSelectableMixin<Todo, void> {}
The behavior is provided by the mixins. Your code stays focused on the domain.
What You Get #
Lists #
- Infinite scrolling
- Debounced search
- Search pagination
- Pull-to-refresh
- Selection and multi-selection
- Highlighting
- Expansion
- Scroll-to-item
- Stream synchronization
Forms #
- Validation
- Async uniqueness checks
- Multi-step forms
- Field-level errors
- Submission workflows
Architecture #
- Pure Dart
- No Flutter dependency
- flutter_bloc compatible
- Use-case driven
- Typed error handling
- Composable feature mixins
Framework-agnostic.
blocx_corehas no Flutter dependency. Pair it withflutter_blocxfor ready-made UI widgets built on top of this core.
Is BlocX Right For Me? #
Use BlocX if:
- You already use flutter_bloc
- You have many list screens
- You repeatedly implement pagination and search
- You want consistency across projects
You may not need BlocX if:
- Your application only contains a few simple screens
- You prefer Riverpod-style state management
- You want minimal abstractions
Architecture Philosophy #
BlocX is a composable application framework built on top of the BLoC pattern that removes repetitive state-management infrastructure while keeping business logic explicit.
Build only what your screen requires:
- Pagination
- Search
- Refresh
- Selection
- Highlighting
- Expansion
- Forms
- Validation
Nothing more.
Table of Contents #
- Installation
- Architecture Overview
- Core Concepts
- List BLoC
- Form BLoC
- Error & Screen Management
- Quickstart: Paged & Searchable List
- Quickstart: Form with Validation
- Migrating from 0.7.x
- Contributing
- License
Installation #
Add blocx_core to your pubspec.yaml:
dependencies:
blocx_core: ^0.8.0
Or install via the command line:
dart pub add blocx_core
# Inside a Flutter project:
flutter pub add blocx_core
Import the library:
import 'package:blocx_core/blocx_core.dart';
// For form-specific types:
import 'package:blocx_core/form_bloc.dart';
Requirements: Dart SDK >=3.5.0
Architecture Overview #
blocx_core is organised around three pillars:
┌─────────────────────────────────────────────────┐
│ Your Domain BLoC │
│ extends BlocxListBloc / BlocxFormBloc │
│ with <only the mixins you need> │
└───────────────────┬─────────────────────────────┘
│ delegates async work to
┌───────────────────▼─────────────────────────────┐
│ Use Cases │
│ BlocxBaseUseCase → UseCaseResult<T> │
│ BlocxPaginatedUseCase / SearchUseCase │
└───────────────────┬─────────────────────────────┘
│ UI intents via
┌───────────────────▼─────────────────────────────┐
│ ScreenManagerCubit │
│ Emits snackbar / error-page / pop intents │
│ UI layer decides how to render them │
└─────────────────────────────────────────────────┘
Core Concepts #
BlocxBaseEntity #
All domain objects used with list blocs must extend BlocxBaseEntity. It provides stable identity and equality semantics based on a unique id.
class Product extends BlocxBaseEntity {
@override
final String id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
The identifier getter (also on BlocxBaseEntity) is used internally for scroll-to operations.
UseCase & UseCaseResult #
Every piece of async business logic is encapsulated in a BlocxBaseUseCase<T> subclass. Use cases return a UseCaseResult<T>, which is either a success carrying data or a failure carrying an error and optional stack trace.
class FetchProducts extends BlocxBaseUseCase<List<Product>> {
final ProductRepository repo;
FetchProducts({required this.repo});
@override
Future<UseCaseResult<List<Product>>> perform() async {
try {
final data = await repo.getAll();
return UseCaseResult.success(data);
} catch (e, s) {
return UseCaseResult.failure(e, stackTrace: s);
}
}
}
For paginated data, extend BlocxPaginatedUseCase<T> (which adds loadCount and offset parameters) or SearchUseCase<T> (which additionally provides searchText).
Page<T> #
Page<T> is the normalized container for a page of items returned by pagination use cases. It carries the list of items and signals whether the end of the data source has been reached.
// successResult() is a helper on BlocxPaginatedUseCase that
// wraps a List<T> into a Page<T> automatically.
return successResult(items);
// To signal the last page:
return successResult(items, isLastPage: true);
BlocxListBloc #
BlocxListBloc<T, P> is the central class for list state management, where T is your entity type and P is an optional payload type passed when loading the initial page (use void if no payload is needed).
Extend it and compose only the mixins you require. Each mixin is initialized via a corresponding init*() call in the constructor.
BlocxFormBloc #
BlocxFormBloc<F, P, E> manages a form backed by a BlocxBaseFormEntity subclass (F), an optional initialization payload (P), and an enum (E) that enumerates the form's fields.
ScreenManagerCubit #
ScreenManagerCubit is owned and managed internally by BaseBloc — you no longer need to construct or pass one explicitly. Simply call super(initialState) in your bloc's constructor:
class CounterBloc extends BaseBloc<CounterEvent, CounterState> {
CounterBloc() : super(CounterStateInitial());
}
ScreenManagerCubit acts as a communication channel between your BLoC layer and the presentation layer. Instead of importing Flutter from within a BLoC, you emit typed intents that the UI listens to and renders.
Available intent methods (callable from any bloc):
| Method | Emitted State |
|---|---|
displaySnackBar(...) |
ScreenManagerCubitStateDisplaySnackbar |
displayErrorWidget(...) |
ScreenManagerCubitStateDisplayErrorPage |
displayErrorWidgetByErrorCode(...) |
ScreenManagerCubitStateDisplayErrorPageByErrorCode |
pop() |
ScreenManagerCubitStatePop |
List BLoC #
Available List Mixins #
Mix these into your BlocxListBloc subclass.
| Mixin | Capability |
|---|---|
BlocxCollectionInfiniteMixin |
Next-page loading, reached-end flag, scroll-triggered pagination |
BlocxCollectionSearchableMixin |
Debounced search, search-next-page, search-refresh |
BlocxCollectionRefreshableMixin |
Pull-to-refresh semantics |
BlocxCollectionSelectableMixin |
Single and multi-item selection and deselection |
BlocxCollectionHighlightableMixin |
Highlight and clear-highlight on individual items |
BlocxCollectionExpandableMixin |
Expand, collapse, and toggle expansion on individual items |
BlocxCollectionScrollableMixin |
Programmatic scroll-to-item and scroll-to-identifier |
BlocxCollectionDeletableMixin |
Remove single items, remove by ID, remove multiple items |
BlocxCollectionSyncStreamMixin |
Sync list state from an external stream |
Available List Events #
| Event | Description |
|---|---|
BlocxListEventLoadInitialPage<T, P> |
Load the first page of data |
BlocxListEventLoadNextPage<T> |
Append the next page to the existing list |
BlocxListEventRefreshData<T> |
Reload the list from the source |
BlocxListEventSearch<T> |
Run a debounced search query |
BlocxListEventSearchNextPage<T> |
Load the next page of search results |
BlocxListEventSearchRefresh<T> |
Refresh the current search results |
BlocxListEventClearSearch<T> |
Clear search and restore the base list |
BlocxListEventSelectItem<T> |
Select a single item |
BlocxListEventDeselectItem<T> |
Deselect a single item |
BlocxListEventSelectMultipleItems<T> |
Select multiple items at once |
BlocxListEventDeselectMultipleItems<T> |
Deselect multiple items at once |
BlocxListEventClearSelection<T> |
Clear all selections |
BlocxListEventHighlightItem<T> |
Highlight a specific item |
BlocxListEventClearHighlightedItem<T> |
Clear the highlight on an item |
BlocxListEventExpandItem<T> |
Expand an item's details |
BlocxListEventCollapseItem<T> |
Collapse an item's details |
BlocxListEventToggleItemExpansion<T> |
Toggle expansion state of an item |
BlocxListEventScrollToItem<T> |
Scroll to a given item |
BlocxListEventScrollToIdentifier<T> |
Scroll to an item by its identifier |
BlocxListEventAddItem<T> |
Insert an item into the list |
BlocxListEventUpdateItem<T> |
Replace an item in the list |
BlocxListEventRemoveItem<T> |
Remove a single item |
BlocxListEventRemoveItemById<T> |
Remove an item by its ID |
BlocxListEventRemoveMultipleItems<T> |
Remove multiple items at once |
BlocxListEventReplaceList<T> |
Replace the entire list |
Available List States #
| State | Description |
|---|---|
BlocxListStateLoading<T> |
Initial load or refresh in progress |
BlocxListStateLoaded<T> |
Data is available |
BlocxListStateError<T> |
An error occurred while loading |
BlocxListStateSelectionChanged<T> |
Selection has been updated |
BlocxListStateScrollToItem<T> |
Scroll-to intent emitted |
Use the ListStateExtensions extension on BlocxListState<T> for convenience accessors.
Form BLoC #
BaseFormEntity #
Your form's data model must extend BlocxBaseFormEntity<F, E>, where F is the form entity itself and E is an enum enumerating the form's fields. The entity must be immutable and implement copyWith.
enum ProfileField { name, email, phone }
class ProfileForm extends BlocxBaseFormEntity<ProfileForm, ProfileField> {
final String name;
final String email;
final String phone;
const ProfileForm({
this.name = '',
this.email = '',
this.phone = '',
});
@override
ProfileForm copyWith({String? name, String? email, String? phone}) =>
ProfileForm(
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
);
}
Built-in Validators #
Validators extend BlocxFieldValidator<T> and are composed per field inside a BlocxFormValidator subclass.
| Validator | Description |
|---|---|
BlocxRequiredValidator |
Field must not be null or empty |
BlocxMinLengthValidator |
String must have at least N characters |
BlocxMaxLengthValidator |
String must not exceed N characters |
BlocxExactLengthValidator |
String must be exactly N characters |
BlocxLengthRangeValidator |
String length within [min, max] |
BlocxRegexValidator |
String must match a regular expression |
BlocxMinValueValidator<T> |
Numeric value >= min |
BlocxMaxValueValidator<T> |
Numeric value <= max |
BlocxRangeValueValidator |
Numeric value within [min, max] |
BlocxMinDateValidator |
DateTime not before minDate |
BlocxMaxDateValidator |
DateTime not after maxDate |
BlocxDateRangeValidator |
DateTime within [minDate, maxDate] |
BlocxMatchFieldValidator<T> |
Field value must match another field's value |
BlocxConditionalRequiredValidator |
Required only when a condition is true |
Form Events #
| Event | Description |
|---|---|
BlocxFormEventInit<P> |
Initialize the form, optionally with a payload |
BlocxFormEventFetchRequiredInfo |
Fetch any data the form depends on before rendering |
BlocxFormEventUpdateData<E> |
Update the value of a single field |
BlocxFormEventUpdateFormData<P> |
Replace the entire form data object |
BlocxFormEventSubmit |
Validate and submit the form |
BlocxFormEventSetErrorToField<E> |
Manually set an error on a specific field |
BlocxFormEventSetTimedErrorToField<E> |
Set a time-limited error on a field |
BlocxFormEventClearFieldError<E> |
Clear the error on a specific field |
BlocxFormEventCheckUniqueValue<E> |
Trigger async uniqueness check for a field |
BlocxFormEventNextStep |
Advance to the next step (stepped forms) |
BlocxFormEventPreviousStep |
Return to the previous step (stepped forms) |
BlocxFormEventGoToStep |
Jump to a specific step (stepped forms) |
Form States #
| State | Description |
|---|---|
BlocxFormStateInitial<F, E> |
Form not yet initialized |
BlocxFormStateLoaded<F, E> |
Form loaded and ready for interaction |
BlocxFormStateFormUpdated<F, E> |
A field value or error has changed |
BlocxFormStateApplyInitialDataToForm<F, E> |
Initial data applied to the form |
BlocxFormStateSubmittingForm<F, E> |
Submission in progress |
BlocxFormStateFormSubmitted<F, E> |
Submission completed successfully |
Form Mixins #
| Mixin | Capability |
|---|---|
BlocxFormValidationMixin |
Per-field and whole-form validation |
BlocxFormErrorsMixin |
Programmatic error setting and clearing |
BlocxFormInfoFetcherMixin |
Fetch remote data required before the form is ready |
BlocxFormSteppedMixin |
Multi-step form navigation (next, previous, go-to) |
BlocxUniqueFieldValidatorMixin |
Async server-side uniqueness validation per field |
Use Case Tasks #
BlocxUseCaseTask and BlocxPaginatedUseCaseTask pair a use case with a lazily evaluated input builder, so input is always constructed from the latest runtime state at execution time rather than at registration time.
BlocxUseCaseTask #
BlocxUseCaseTask(
useCase: getUserUseCase,
inputBuilder: () => GetUserInput(id: currentUserId),
);
BlocxPaginatedUseCaseTask #
Use this as the standard task type for BlocxCollectionBloc.paginationTask. The inputBuilder receives the current limit (page size) and offset (number of already-loaded items) at execution time:
@override
BlocxPaginatedUseCaseTask get paginationTask => BlocxPaginatedUseCaseTask(
useCase: _getOrdersUseCase,
inputBuilder: ({required limit, required offset}) =>
BlocxPaginationInput(limit: limit, offset: offset),
);
To include extra fields from bloc state:
@override
BlocxPaginatedUseCaseTask get paginationTask => BlocxPaginatedUseCaseTask(
useCase: _getOrdersUseCase,
inputBuilder: ({required limit, required offset}) => GetOrdersInput(
limit: limit,
offset: offset,
userId: payload!.id,
status: currentFilter,
),
);
If initial load, next-page, and refresh each hit different endpoints, override BlocxCollectionBloc.loadInitialPageTask individually instead.
Error & Screen Management #
Any bloc can emit UI intents without importing Flutter. Error handling is built into BaseBloc — call handleError from event handlers to log and surface errors via the configured errorDisplayPolicy (snackbar by default):
} catch (e, st) {
handleError(e, emit, stacktrace: st);
}
To display a full-page error instead, override errorDisplayPolicy in your bloc:
@override
ErrorDisplayPolicy get errorDisplayPolicy => ErrorDisplayPolicy.page;
Register a BlocxErrorTranslator once at app startup to map raw exceptions to human-readable ReadableError instances — blocs pick it up automatically.
The presentation layer listens to ScreenManagerCubit and handles each intent:
// Inside a BLoC event handler:
displaySnackBar(
message: 'Item deleted successfully.',
type: BlocXSnackbarType.success,
);
displayErrorWidget(
error: ReadableError(title: 'Not Found', message: 'The resource could not be loaded.'),
);
pop();
// In your Flutter widget or BlocListener:
BlocListener<ScreenManagerCubit, ScreenManagerCubitState>(
bloc: screenCubit,
listener: (context, state) {
if (state is ScreenManagerCubitStateDisplaySnackbar) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
} else if (state is ScreenManagerCubitStatePop) {
Navigator.of(context).pop();
}
},
);
BlocXErrorCode and BlocXSnackbarType enums give you typed control over the intent payload.
Quickstart: Paged & Searchable List #
The following example wires up a fully paginated, searchable, refreshable, and selectable list for a Todo entity.
1. Define the Entity #
import 'package:blocx_core/blocx_core.dart';
class Todo extends BlocxBaseEntity {
@override
final String id;
final String title;
final bool completed;
const Todo({required this.id, required this.title, this.completed = false});
}
2. Define the Repository Contract #
abstract class TodoRepository {
Future<List<Todo>> fetchPage({required int limit, required int offset});
Future<List<Todo>> search({required String query, required int limit, required int offset});
}
3. Implement Use Cases #
class FetchTodosUseCase extends BlocxPaginatedUseCase<Todo> {
final TodoRepository repo;
FetchTodosUseCase({
required this.repo,
required super.loadCount,
required super.offset,
});
@override
Future<UseCaseResult<Page<Todo>>> perform() async {
try {
final items = await repo.fetchPage(limit: loadCount, offset: offset);
return successResult(items);
} catch (e, s) {
return UseCaseResult.failure(e, stackTrace: s);
}
}
}
class SearchTodosUseCase extends SearchUseCase<Todo> {
final TodoRepository repo;
SearchTodosUseCase({
required this.repo,
required super.searchText,
required super.loadCount,
required super.offset,
});
@override
Future<UseCaseResult<Page<Todo>>> perform() async {
try {
final items = await repo.search(
query: searchText,
limit: loadCount,
offset: offset,
);
return successResult(items);
} catch (e, s) {
return UseCaseResult.failure(e, stackTrace: s);
}
}
}
4. Compose the BLoC #
class TodosBloc extends BlocxListBloc<Todo, void>
with
BlocxCollectionInfiniteMixin<Todo, void>,
BlocxCollectionSearchableMixin<Todo, void>,
BlocxCollectionRefreshableMixin<Todo, void>,
BlocxCollectionSelectableMixin<Todo, void> {
final TodoRepository repo;
TodosBloc({required this.repo}) : super(BlocxInfiniteListBloc()) {
initInfiniteList();
initSearchable();
initRefresh();
initSelectable();
add(BlocxListEventLoadInitialPage<Todo, void>());
}
@override
BlocxPaginatedUseCase<Todo>? get loadInitialPageUseCase =>
FetchTodosUseCase(repo: repo, loadCount: 20, offset: 0);
@override
BlocxPaginatedUseCase<Todo>? get loadNextPageUseCase =>
FetchTodosUseCase(repo: repo, loadCount: 20, offset: list.length);
@override
BlocxPaginatedUseCase<Todo>? get refreshPageUseCase =>
FetchTodosUseCase(repo: repo, loadCount: list.length, offset: 0);
@override
SearchUseCase<Todo>? searchUseCase(String q, {int? loadCount, int? offset}) =>
SearchTodosUseCase(
repo: repo,
searchText: q,
loadCount: loadCount ?? 20,
offset: offset ?? 0,
);
@override
(String, String?) convertErrorToMessageAndTitle(Object error) =>
('Failed to load todos. Please try again.', null);
}
Note:
ScreenManagerCubitis now owned internally byBaseBloc. Thescreenparameter has been removed from the constructor — just callsuper(initialState).
5. Drive the BLoC #
final bloc = TodosBloc(repo: myRepo);
// Pagination
bloc.add(BlocxListEventLoadNextPage<Todo>());
// Search
bloc.add(BlocxListEventSearch<Todo>(searchText: 'urgent'));
bloc.add(BlocxListEventClearSearch<Todo>());
// Selection
bloc.add(BlocxListEventSelectItem<Todo>(item: someTodo));
bloc.add(BlocxListEventClearSelection<Todo>());
// Refresh
bloc.add(BlocxListEventRefreshData<Todo>());
Quickstart: Form with Validation #
1. Define the Field Enum and Form Entity #
import 'package:blocx_core/form_bloc.dart';
enum SignUpField { email, password, confirmPassword }
class SignUpForm extends BlocxBaseFormEntity<SignUpForm, SignUpField> {
final String email;
final String password;
final String confirmPassword;
const SignUpForm({
this.email = '',
this.password = '',
this.confirmPassword = '',
});
@override
SignUpForm copyWith({
String? email,
String? password,
String? confirmPassword,
}) =>
SignUpForm(
email: email ?? this.email,
password: password ?? this.password,
confirmPassword: confirmPassword ?? this.confirmPassword,
);
}
2. Define the Validator #
class SignUpValidator extends BlocxFormValidator<SignUpForm, SignUpField> {
@override
Map<SignUpField, List<BlocxFieldValidator>> get validators => {
SignUpField.email: [
BlocxRequiredValidator(),
BlocxRegexValidator(
pattern: r'^[^@]+@[^@]+\.[^@]+$',
errorMessage: 'Enter a valid email address.',
),
],
SignUpField.password: [
BlocxRequiredValidator(),
BlocxMinLengthValidator(minLength: 8),
],
SignUpField.confirmPassword: [
BlocxRequiredValidator(),
BlocxMatchFieldValidator<String>(
otherFieldValue: (form) => form.password,
errorMessage: 'Passwords do not match.',
),
],
};
}
3. Implement the FormBloc #
class SignUpBloc extends BlocxFormBloc<SignUpForm, void, SignUpField>
with BlocxFormValidationMixin<SignUpForm, void, SignUpField> {
SignUpBloc() : super(const SignUpForm(), SignUpValidator());
@override
Future<void> onSubmit(SignUpForm form) async {
// Perform submission logic, e.g. call a use case.
// Call displaySnackBar or pop() on success/failure.
}
}
4. Interact with the FormBloc #
// Update a field value:
bloc.add(BlocxFormEventUpdateData<SignUpField>(
field: SignUpField.email,
value: 'user@example.com',
));
// Submit the form:
bloc.add(BlocxFormEventSubmit());
Migrating from 0.7.x #
Breaking: mixin renames #
All collection mixin names have had the redundant _bloc segment removed for a cleaner, consistent naming scheme. Update your with clauses and any direct imports:
| Before (0.7.x) | After (0.8.0) |
|---|---|
BlocxInfiniteListBlocMixin |
BlocxCollectionInfiniteMixin |
BlocxSelectableListBlocMixin |
BlocxCollectionSelectableMixin |
BlocxRefreshableListBlocMixin |
BlocxCollectionRefreshableMixin |
BlocxSearchableListBlocMixin |
BlocxCollectionSearchableMixin |
BlocxDeletableListBlocMixin |
BlocxCollectionDeletableMixin |
BlocxExpandableListBlocMixin |
BlocxCollectionExpandableMixin |
BlocxHighlightableListBlocMixin |
BlocxCollectionHighlightableMixin |
BlocxScrollableListBlocMixin |
BlocxCollectionScrollableMixin |
BlocxListBlocSyncStreamMixin |
BlocxCollectionSyncStreamMixin |
Form mixins follow the same blocx_form_* prefix pattern:
| Before (0.7.x) | After (0.8.0) |
|---|---|
BlocxInfoFetcherFormMixin |
BlocxFormInfoFetcherMixin |
BlocxSteppedFormMixin |
BlocxFormSteppedMixin |
Breaking: model rename #
BaseFormEntity is now BlocxBaseFormEntity. Update all subclasses and type references.
Breaking: ScreenManagerCubit ownership #
ScreenManagerCubit is now owned internally by BaseBloc. Remove the screen parameter from your bloc constructors and call sites:
// Before
MyBloc({required ScreenManagerCubit screen}) : super(screen, MyStateInitial());
// After
MyBloc() : super(MyStateInitial());
pubspec constraint #
dependencies:
blocx_core: ^0.8.0
Contributing #
Contributions are welcome. Please follow these guidelines:
- Code style: Run
dart format .before committing. All lints inanalysis_options.yamlmust pass (dart analyze). - Documentation: All public APIs must be documented with dartdoc comments.
- Tests: Add or update tests for every new mixin, event, state, or validator. Run
dart testto verify the full test suite passes. - Pull requests: Keep changes focused. One feature or fix per pull request.
License #
This project is licensed under the MIT License. See the LICENSE file at the repository root for details.