flutter_blocx 0.8.4
flutter_blocx: ^0.8.4 copied to clipboard
Ui widgets to work with blocx_core.
flutter_blocx
Production-ready Flutter widgets for lists, grids, and forms.
The UI layer for blocx_core — zero paging flags, zero scroll listeners, zero boilerplate.
Why flutter_blocx? #
Most Flutter apps do not become hard to maintain because the UI is visually complex. They become hard to maintain because every data-driven screen quietly rebuilds the same infrastructure:
- load the first page
- show loading, empty, and error states
- listen for scroll-end pagination
- refresh safely
- search with debounce
- track selected, highlighted, expanded, and deleting items
- wire text controllers and focus nodes
- hydrate edit forms
- show validation errors
- block duplicate submits
- show async uniqueness checks
- route errors into snackbars or full-page states
flutter_blocx turns that repeated infrastructure into reusable Flutter screen primitives.
It is the presentation layer for blocx_core. blocx_core owns the application-state patterns: use cases, results, form blocs, collection blocs, pagination, validation, and screen side effects. flutter_blocx makes those patterns practical in Flutter by giving you ready-to-use widgets and state classes for lists, grids, forms, search fields, submit buttons, confirmation flows, and screen-level error handling.
Instead of writing scroll listeners, controller maps, loading flags, submit guards, and BlocConsumer wiring in every feature, you describe what the screen is:
BlocxCollectionWidgetState<UsersScreen, User, void>
BlocxFormWidgetState<EditUserScreen, UserForm, User, UserField>
Then you override the parts that are actually unique to your feature:
generateBloc
itemBuilder
formWidget
keys
settings
topWidget
onFormSubmitted
Use flutter_blocx when you want:
- consistent list and form architecture across your app
- less repetitive Flutter/BLoC glue code
- pagination, refresh, search, selection, and form submit behavior handled once
- clean separation between use cases, blocs, and widgets
- predictable screen side effects through
ScreenManagerCubit - reusable UI primitives that still let you override the final design
The package is built for apps with many repeated CRUD and workflow screens: admin panels, dashboards, finance apps, internal tools, back-office systems, and server-driven mobile apps.
The goal is simple: write feature code, not infrastructure code.
What it provides #
Collection screens
Build lists and grids backed by BlocxCollectionBloc with support for:
- infinite scrolling
- pull-to-refresh
- searching
- item selection
- item highlighting
- item expansion
- animated insert/remove operations
- sliver and grid layouts
- programmatic scrolling
- owned and externally provided scroll controllers
Instead of manually coordinating scroll controllers, pagination triggers, loading indicators, empty states, and item-state events, BlocxCollectionWidgetState gives you a consistent collection-screen host.
Form screens
Create forms powered by BlocxFormBloc with built-in:
TextEditingControllermanagementFocusNodemanagement- validation error display
- async uniqueness checks
- submit state handling
- dropdowns and checkboxes
- initial data hydration
- form update tracking
- safe submit-time validation UX
BlocxFormWidgetState synchronizes Flutter form controls with your form entity and bloc state, eliminating repetitive form wiring code.
Screen management
All screen host states integrate with ScreenManagerCubit, providing a unified way to:
- show snackbars
- display full-screen errors
- handle retry flows
- trigger navigation pops
- surface error-code-based messages
This keeps UI side effects separate from business logic while remaining easy to consume from Flutter widgets.
Shared UX components
The package also includes reusable widgets for common application patterns:
- search fields
- confirmation dialogs
- error pages
- submit button states
- floating-action-button visibility management
- localization support
Designed to work with blocx_core #
flutter_blocx is not a replacement for blocx_core; it is its Flutter presentation layer.
Together they provide a complete architecture where:
- use cases handle business operations
- blocs manage application state
- mixins compose reusable behaviour
- widgets render and interact with that state
The result is a consistent, scalable approach for building CRUD applications, dashboards, admin panels, business tools, and any Flutter app that relies heavily on lists, forms, and server-driven data.
Table of Contents #
- Installation
- Setup
- Lists & Collections
- Forms
- Screen Management
- Shared Utilities
- Localization
- Quickstart: Collection Screen
- Quickstart: Form Screen
- Contributing
- License
Installation #
Add both packages to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
blocx_core: ^0.8.2
flutter_blocx: ^0.8.4
Or install via the command line:
flutter pub add flutter_blocx
Import the library:
// Everything: screen utilities, collection widgets, form widgets, shared widgets.
import 'package:flutter_blocx/flutter_blocx.dart';
// Collection / list widgets and state.
import 'package:flutter_blocx/list_widget.dart';
// Form widgets and state.
import 'package:flutter_blocx/form_widget.dart';
// Stateful collection item base, if needed separately.
import 'package:flutter_blocx/blocx_collection_item_state.dart';
Setup #
flutter_blocx uses BlocXLocalizations from blocx_core for framework copy such as loading text, empty-list text, validator messages, error labels, and button labels.
A default English localization is available, so the package works without setup. For production apps, localization, or custom wording, register your own implementation before runApp:
import 'package:blocx_core/blocx_core.dart';
void main() {
BlocXLocalizations.localizations = AppLocalizations();
runApp(const MyApp());
}
class AppLocalizations extends BlocXLocalizations {
@override
String get tryAgain => 'Try again';
// ....
@override
String fileSizeMustBeSmallerThan(String format) {
return 'File size must be smaller than $format';
}
}
Lists & Collections #
The collection API is built around a host widget/state pair:
BlocxCollectionWidget<P>
BlocxCollectionWidgetState<W, T, P>
Use these two classes first. They create the screen-level structure that all collection widgets plug into.
BlocxCollectionWidget<P> is the outer StatefulWidget. It carries an optional payload into the screen. Use that payload for route arguments, filters, parent IDs, or any value needed to load the initial collection.
BlocxCollectionWidgetState<W, T, P> is the collection host. It owns the collection bloc, creates or attaches the scroll controller, dispatches the initial load event, renders loading/empty/list states, and connects screen side effects through ScreenManagerCubit.
After this host state exists, you can wire in collection widgets such as:
BlocxCollectionItemBlocxStatefulCollectionItemBlocxSearchFieldInfiniteListAnimatedInfiniteListInfiniteGrid- sliver list/grid variants
Mental model #
BlocxCollectionWidget
owns route/payload
BlocxCollectionWidgetState
owns bloc lifecycle
owns scroll controller lifecycle
renders loading / empty / collection states
exposes helper methods
Collection widgets
read the collection bloc from context
dispatch item/search/selection/delete events
BlocxCollectionWidget & BlocxCollectionWidgetState #
These two classes are your entry point for any list or grid screen.
class UsersScreen extends BlocxCollectionWidget<void> {
const UsersScreen({super.key});
@override
State<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState
extends BlocxCollectionWidgetState<UsersScreen, User, void> {
@override
BlocxCollectionBloc<User, void> get generateBloc {
return UsersBloc();
}
@override
Widget itemBuilder(BuildContext context, User item) {
return UserCard(item: item);
}
}
How bloc initialisation works
You do not pass the bloc as a constructor parameter. Instead, override the generateBloc getter. The state calls it once in initState, stores the result privately, and disposes it automatically when the widget is removed from the tree when autoDisposeBloc is true.
Internally, initState also:
- creates the correct
ScrollController - uses an
AutoScrollControllerwhen the bloc supportsBlocxCollectionScrollableMixin - uses a plain
ScrollControllerotherwise - attaches
_onScrollonly when needed - dispatches
BlocxCollectionEventLoadInitialPagewithwidget.payloadwhenloadOnInitistrue
If you override scrollControllerProvider, the state uses your external controller and does not dispose it.
What the state renders
The state wraps everything in a BlocConsumer tied to the bloc. On every rebuild it calls collectionWrapperBuilder, which stacks:
topWidget, if overridden- spacing
- loading, empty, or the actual collection body
- spacing
bottomWidget, if overridden
The core body is one of:
loadingWidgetwhileisLoading || isSearchingemptyWidgetwhen the list is empty after loadingcollectionWidgetotherwise
The default wrapper is a Column(crossAxisAlignment: CrossAxisAlignment.stretch). The core body is wrapped in Expanded unless settings.options.shrinkWrap is true.
Collection host handles #
Inside a BlocxCollectionWidgetState, you get these useful handles:
| Member | Type | Description |
|---|---|---|
generateBloc |
BlocxCollectionBloc<T, P> |
Required. Instantiate and return your bloc here. |
bloc |
BlocxCollectionBloc<T, P> |
Read-only access to the created bloc instance. |
payload |
P? |
The widget payload passed into BlocxCollectionWidget. |
scrollController |
ScrollController? |
The active scroll controller. |
scrollControllerProvider |
ScrollController? |
Override to provide your own scroll controller. External controllers are not disposed by the state. |
isLoading |
bool |
true when the current state is BlocxCollectionStateLoading. |
isSearching |
bool |
true when search is active. |
settings |
CollectionSettings |
Controls list/grid/sliver/animated rendering. |
loadOnInit |
bool |
Whether to load the initial page automatically. Default: true. |
autoDisposeBloc |
bool |
Whether to close the bloc in dispose. Default: true. |
topWidget(context, state) |
Widget? |
Optional UI above the collection, such as search or filters. |
bottomWidget(context, state) |
Widget? |
Optional UI below the collection. |
sliverTopWidget(context, state) |
Widget? |
Optional sliver before sliver collections. |
sliverBottomWidget(context, state) |
Widget? |
Optional sliver after sliver collections. |
topBottomAndListSpacing |
double |
Gap between optional top/bottom widgets and the collection. |
searchingText |
String |
Text shown while a search is running. |
Collection host actions #
BlocxCollectionWidgetState exposes helper methods so screens do not need to manually dispatch events:
| Method | Description |
|---|---|
refreshData() |
Dispatches BlocxCollectionEventRefreshData. |
loadNextPage() |
Dispatches BlocxCollectionEventLoadNextPage. |
search(text) |
Dispatches BlocxCollectionEventSearch. |
scrollToItem(item, highlightItem: true) |
Scrolls to an item when the bloc supports scrolling. |
addToList(item, index: 0) |
Dispatches BlocxCollectionEventAddItem. |
deleteMultipleItems(items) |
Dispatches BlocxCollectionEventRemoveMultipleItems. |
deselectMultipleItems(items) |
Dispatches BlocxCollectionEventDeselectMultipleItems. |
Override points #
| Method / getter | Description |
|---|---|
generateBloc |
Required. Return a new bloc instance. |
itemBuilder(context, item) |
Required. Render one collection item. |
settings |
Choose the widget type and options. Default: animatedList with AnimatedInfiniteListOptions(). |
topWidget(context, state) |
Widget above the collection. |
bottomWidget(context, state) |
Widget below the collection. |
sliverTopWidget(context, state) |
Sliver before sliver collections. |
sliverBottomWidget(context, state) |
Sliver after sliver collections. |
loadingWidget(context, state) |
Override loading UI. |
emptyWidget(context, state) |
Override empty UI. |
separatorBuilder(context, index) |
Build separators between items. |
loadMoreWidgetBuilder(context, isLoadingMore) |
Custom load-more indicator. |
refreshWidgetBuilder(context, height) |
Custom pull-to-refresh indicator. |
deleteAnimation |
Animation builder used when items are removed from an animated list. |
insertAnimation |
Animation builder used when items are inserted. |
blocListener(context, state) |
Hook into listen-only collection states. |
onSelectionChanged(context, data) |
Called when selection changes. |
wrapInScaffold |
Return true to use scaffoldWidget. |
scaffoldWidget(context, body) |
Build a scaffold when wrapInScaffold is true. |
decorateScaffold(scaffold) |
Wrap the scaffold, for example with PopScope. |
Collection widgets that plug into the host #
Once your screen extends BlocxCollectionWidgetState, the widgets below can be wired into it.
BlocxCollectionItem #
BlocxCollectionItem<T, P> is the stateless base widget for individual list rows and cards. It resolves the parent BlocxCollectionBloc<T, P> from the widget tree automatically and exposes context-aware state flags and action helpers for that specific item.
class UserCard extends BlocxCollectionItem<User, void> {
const UserCard({super.key, required super.item});
@override
Widget buildContent(BuildContext context, User item) {
return ListTile(
title: Text(item.displayName),
subtitle: Text(item.email),
selected: isSelected(context),
onTap: () => toggleSelection(context),
onLongPress: () => removeItem(context),
);
}
}
Reactive state flags:
| Flag | Description |
|---|---|
isSelected(context) |
Item id is in state.selectedItemIds. |
isHighlighted(context) |
Item id is in state.highlightedItemIds. |
isBeingRemoved(context) |
Item id is in state.beingRemovedItemIds. |
isBeingSelected(context) |
Item id is in state.beingSelectedItemIds. |
isExpanded(context) |
Item id is in state.expandedItemIds. |
Action helpers:
| Method | Required mixin |
|---|---|
selectItem(context) |
BlocxCollectionSelectableMixin |
deselectItem(context) |
BlocxCollectionSelectableMixin |
toggleSelection(context) |
BlocxCollectionSelectableMixin |
highlightItem(context) |
BlocxCollectionHighlightableMixin |
clearHighlightedItem(context) |
BlocxCollectionHighlightableMixin |
toggleExpansion(context) |
BlocxCollectionExpandableMixin |
removeItem(context) |
BlocxCollectionDeletableMixin |
updateItem(context, item) |
none |
insertItem(context, item, index: 0) |
none |
toggleAllItemsSelection(context) |
BlocxCollectionSelectableMixin |
toggleMultipleItemsSelection(context, items, areSelected) |
BlocxCollectionSelectableMixin |
Deletion confirmation is enabled by default. Calling removeItem shows ConfirmActionWidget as a modal bottom sheet before dispatching the delete event. Override confirmDeleteOptions to configure the sheet, or set confirmBeforeDelete to false to skip it.
@override
bool get confirmBeforeDelete => false;
@override
ConfirmActionOptions get confirmDeleteOptions {
return ConfirmActionOptions(
title: 'Remove ${item.displayName}',
question: 'This cannot be undone.',
imageUrl: item.avatarUrl,
);
}
BlocxStatefulCollectionItem & BlocxCollectionItemState #
When a list item needs its own local state, such as an animation controller, a focus node, or local expansion state, use BlocxStatefulCollectionItem<T> paired with BlocxCollectionItemState<W, T, P>.
The key difference from the stateless version is that state flags and action helpers are getters and methods with no BuildContext argument. context is already available through State.
class ExpandableUserCard extends BlocxStatefulCollectionItem<User> {
const ExpandableUserCard({super.key, required super.item});
@override
State<ExpandableUserCard> createState() => _ExpandableUserCardState();
}
class _ExpandableUserCardState
extends BlocxCollectionItemState<ExpandableUserCard, User, void> {
@override
Widget buildContent(User item) {
return ListTile(
title: Text(item.displayName),
selected: isSelected,
onTap: toggleSelection,
trailing: Icon(isExpanded ? Icons.expand_less : Icons.expand_more),
);
}
}
BlocxSearchField #
BlocxSearchField<T, P> is a pre-wired search input that resolves the parent BlocxCollectionBloc<T, P> from the widget tree and connects to its BlocxCollectionSearchableMixin. It dispatches BlocxCollectionEventSearch on every keystroke and BlocxCollectionEventClearSearch when the field is cleared.
BlocxSearchField requires an explicit TextEditingController. You own its lifecycle.
class _UsersScreenState
extends BlocxCollectionWidgetState<UsersScreen, User, void> {
final _searchController = TextEditingController();
@override
Widget? topWidget(BuildContext context, BlocxCollectionState<User> state) {
return Padding(
padding: const EdgeInsets.all(8),
child: BlocxSearchField<User, void>(
controller: _searchController,
options: const BlocxSearchFieldOptions(
hintText: 'Search users…',
),
),
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
The field throws a descriptive FlutterError if the resolved bloc does not implement BlocxCollectionSearchableMixin.
BlocxSearchFieldOptions key fields:
| Field | Default | Description |
|---|---|---|
hintText |
'Search...' |
Placeholder text. |
prefixIcon |
Icon(Icons.search) |
Leading icon. |
showClearButton |
true |
Show clear button when field has text. |
decoration |
computed | Full InputDecoration override. |
autofocus |
false |
Request focus on first build. |
maxLines |
1 |
Maximum line count. |
Prebuilt Scrolling Widgets #
For cases where you do not need the full BlocxCollectionWidgetState abstraction, flutter_blocx provides standalone scrollable widgets you can embed anywhere. Each requires an itemBuilder, the matching options class, a BlocxInfiniteListBloc reference, and a ScrollController.
List variants:
InfiniteList<T>SliverInfiniteList<T>AnimatedInfiniteList<T>AnimatedSliverInfiniteList<T>
Grid variants:
InfiniteGrid<T>SliverInfiniteGrid<T>
InfiniteList<Product>(
items: state.list,
itemBuilder: (context, item) => ProductCard(item: item),
bloc: productsBloc.infiniteListBloc,
scrollController: scrollController,
loadBottomData: loadNextPage,
refreshOnSwipe: refreshData,
options: InfiniteListOptions(
padding: const EdgeInsets.all(8),
),
)
Choosing the collection layout #
Override settings:
@override
CollectionSettings get settings {
return CollectionSettings(
type: CollectionWidgetStateType.animatedList,
options: AnimatedInfiniteListOptions(),
);
}
CollectionWidgetStateType |
Required options class | Renders as |
|---|---|---|
animatedList |
AnimatedInfiniteListOptions |
Animated ListView. |
list |
InfiniteListOptions |
Standard infinite ListView. |
sliverList |
SliverInfiniteListOptions |
Sliver list. |
animatedSliverList |
AnimatedSliverInfiniteListOptions |
Animated sliver list. |
grid |
InfiniteGridOptions |
Infinite GridView. |
sliverGrid |
SliverInfiniteGridOptions |
Sliver grid. |
The options class must match the type. A mismatch throws an ArgumentError at runtime.
Common option fields:
| Field | Default | Description |
|---|---|---|
shrinkWrap |
false |
Shrink-wrap the scroll view. |
reverse |
false |
Reverse scroll direction. |
scrollDirection |
Axis.vertical |
Scroll axis. |
scrollPhysics |
null |
Custom scroll physics. |
scrollBehavior |
null |
Custom scroll behaviour. |
loadMoreTriggerItemDistance |
2 |
Items from the end that trigger next-page loading. |
HideOnScrollFabMixin #
Mix HideOnScrollFabMixin<T> into any State to automatically show and hide a FloatingActionButton based on scroll direction. Feed scroll events from a NotificationListener<UserScrollNotification> and use getFloatingActionButton(context) to get the animated FAB.
class _MyScreenState extends BlocxCollectionWidgetState<MyScreen, Product, void>
with HideOnScrollFabMixin<Product> {
@override
void onFabPressed(Product? data) {
// Handle tap.
}
@override
Widget scaffoldWidget(BuildContext context, Widget body) {
return Scaffold(
body: NotificationListener<UserScrollNotification>(
onNotification: onScrollNotification,
child: body,
),
floatingActionButton: getFloatingActionButton(context),
);
}
}
The FAB slides and fades out when scrolling down; it returns when scrolling up. Override onFabPressed(T? data) to handle taps.
Forms #
The form API is built around a host widget/state pair:
BlocxFormWidget<P>
BlocxFormWidgetState<W, F, P, E>
Use these two classes first. They create the screen-level structure that all form widgets plug into.
BlocxFormWidget<P> is the outer StatefulWidget. It carries an optional payload into the form. Use that payload for edit/update forms.
BlocxFormWidgetState<W, F, P, E> is the form host. It owns the form bloc, manages text controllers and focus nodes, hydrates initial data, dispatches submit and update events, listens for form-side effects, and connects screen side effects through ScreenManagerCubit.
After this host state exists, you can wire in form widgets such as:
BlocXFormTextFieldBlocXFormDropdownBlocxFormCheckboxBlocxFormRegisterButtonBlocxFormButtonRow
Mental model #
BlocxFormWidget
owns route/payload
BlocxFormWidgetState
owns bloc lifecycle
owns TextEditingController lifecycle
owns FocusNode lifecycle
hydrates edit data
exposes helper methods
Form widgets
read the form bloc from context
dispatch field update / submit events
BlocxFormWidget & BlocxFormWidgetState #
These two classes are your entry point for any form screen.
class SignUpScreen extends BlocxFormWidget<void> {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState
extends BlocxFormWidgetState<SignUpScreen, SignUpForm, void, SignUpField> {
@override
BlocxFormBloc<SignUpForm, void, SignUpField> generateBloc() {
return SignUpBloc();
}
@override
List<SignUpField> get keys {
return SignUpField.values;
}
@override
Widget formWidget(
BuildContext context,
BlocxFormState<SignUpForm, SignUpField> state,
) {
return Column(
children: [
textField(SignUpField.email),
textField(SignUpField.password),
BlocxFormRegisterButton<SignUpForm, void, SignUpField>(
state: state,
buttonText: 'Create account',
submitText: 'Creating…',
onPressed: submit,
),
],
);
}
}
How bloc initialisation works
Override generateBloc(); it is called once in initState. The state then dispatches BlocxFormEventInit(payload: widget.payload) automatically.
If the bloc uses BlocxFormInfoFetcherMixin, the form bloc can fetch required remote data after initialization.
The state manages all TextEditingControllers and FocusNodes through internal maps. They are created on demand and disposed automatically in dispose. Do not manually create controllers for fields built with textField() unless you are bypassing the host helpers intentionally.
What the state listens for
The form host handles these listen-only states automatically:
| State | Behaviour |
|---|---|
BlocxFormStateApplyInitialDataToForm |
Hydrates text controllers from form data. |
BlocxFormStateFormSubmitted |
Calls onFormSubmitted(state). |
BlocxFormStateFormUpdated |
Calls onFormUpdated(formData). |
Form host handles #
Inside a BlocxFormWidgetState, you get these useful handles:
| Member | Type | Description |
|---|---|---|
bloc |
BlocxFormBloc<F, P, E> |
The form bloc created by generateBloc(). |
state |
BlocxFormState<F, E> |
Current typed form state snapshot. |
payload |
P? |
The widget payload. |
isUpdate |
bool |
true when widget.payload != null. |
isValid |
bool |
Whether the current form state has no validation/fetching/unique-check blockers. |
formVerticalSpacing |
double |
Vertical gap between fields. Default: 16. |
autoCloseBloc |
bool |
Whether the bloc closes on dispose. Default: true. |
autovalidateMode |
AutovalidateMode |
Default native Flutter autovalidation mode. |
keys |
List<E> |
Field keys used for text-controller hydration. |
Form host actions #
| Method | Description |
|---|---|
textField(key, options: ..., type: ...) |
Builds a bloc-wired text field with a managed controller. |
dropdown<T>(key, items: ..., options: ...) |
Builds a bloc-wired dropdown. |
checkbox(key: ..., isChecked: ..., options: ...) |
Builds a bloc-wired checkbox. |
getTextEditingController(key) |
Returns the managed controller for a field. |
getFocusNode(key) |
Returns the managed focus node for a field. |
submit() |
Dispatches BlocxFormEventSubmit. |
changeListener(data, key) |
Dispatches a field update for custom widgets. |
setErrorToField(key, message) |
Adds a persistent field error. |
setTimedErrorToField(key, message, duration: ...) |
Adds an auto-clearing field error. |
clearFieldError(key, message: ...) |
Clears one or all errors for a field. |
applyInitialDataToForm(formData) |
Hydrates controllers from form data. |
onFormSubmitted(state) |
Override to navigate or react after success. |
onFormUpdated(formData) |
Override to react to field updates. |
blocListener(context, state) |
Override for extra listen-only states. Call super. |
Form widgets that plug into the host #
Once your screen extends BlocxFormWidgetState, the widgets below can be wired into it.
BlocXFormTextField #
A TextFormField pre-wired to a BlocxFormBloc field. On every keystroke it dispatches BlocxFormEventUpdateData. It reads bloc state directly to display validation errors and shows a spinning CircularProgressIndicator in the suffix while a unique-field check is running.
Prefer the host helper:
textField(
SignUpField.email,
type: TextFieldType.outlined,
options: const BlocXTextFieldOptions(
labelText: 'Email address',
keyboardType: TextInputType.emailAddress,
),
)
Use getTextEditingController(key) when you need direct access to the managed controller:
final emailController = getTextEditingController(SignUpField.email);
BlocXFormTextField supports three visual variants through TextFieldType:
| Value | Renders as |
|---|---|
filled |
Filled background with rounded border. |
outlined |
Material outlined border. |
underlined |
Underline-only field. |
BlocXTextFieldOptions key fields:
| Field | Default | Description |
|---|---|---|
decoration |
null |
Optional base InputDecoration. Bloc-driven errors and suffix loading indicators are still merged into it. |
labelText |
null |
Floating label. |
hintText |
null |
Placeholder. |
obscureText |
false |
Password masking. |
keyboardType |
null |
Keyboard hint. |
textInputAction |
null |
Keyboard action button. |
maxLength |
null |
Character counter. |
enabled |
true |
Whether the field is interactive. |
showClearButton |
true |
Show clear button when the field has text. Hidden for obscure text fields. |
filled |
true |
Whether the filled variant uses a filled background. |
fillColor |
null |
Fill colour for the field background. |
borderRadius |
BorderRadius.circular(12) |
Border radius for default filled and outlined decorations. |
inputFormatters |
[] |
Applied before value reaches the bloc. |
textDirection |
auto-detected | Automatically switches between RTL and LTR while typing. |
suffix |
null |
Custom suffix widget. Overridden by the unique-field spinner. |
contentPadding |
EdgeInsets.symmetric(horizontal: 16, vertical: 8) |
Field padding. |
BlocXFormDropdown #
A DropdownButtonFormField pre-wired to a BlocxFormBloc field. It dispatches BlocxFormEventUpdateData on every selection change and reads bloc state to display validation errors.
Prefer BlocxFormWidgetState.dropdown():
dropdown<String>(
ProfileField.country,
items: countries.map((country) {
return DropdownMenuItem(
value: country.code,
child: Text(country.name),
);
}).toList(),
options: const BlocXDropdownOptions(
labelText: 'Country',
),
)
BlocXDropdownOptions key fields:
| Field | Default | Description |
|---|---|---|
labelText |
null |
Floating label. |
hintText |
null |
Placeholder. |
filled |
true |
Use filled decoration. |
fillColor |
null |
Fill colour. |
isExpanded |
true |
Expand dropdown to available width. |
textStyle |
null |
Text style. |
selectedItemBuilder |
null |
Custom selected item builder. |
errorText |
null |
Static error fallback. |
BlocxFormCheckbox #
A platform-adaptive checkbox pre-wired to a BlocxFormBloc field. It dispatches BlocxFormEventUpdateData on every toggle.
When label or subtitle is set in BlocxCheckboxOptions, it renders as a CheckboxListTile for a larger touch target. Otherwise, it renders as a compact checkbox.
Prefer BlocxFormWidgetState.checkbox():
checkbox(
key: SignUpField.terms,
isChecked: state.formData.acceptedTerms,
options: BlocxCheckboxOptions(
isChecked: state.formData.acceptedTerms,
label: 'I accept the Terms of Service',
),
)
BlocxFormRegisterButton #
A standalone submit button that reacts to BlocxFormState.
It automatically handles:
- idle state
- submitting state
- async unique-field checks
- required field info loading
BlocxFormRegisterButton<SignUpForm, void, SignUpField>(
state: state,
buttonText: 'Sign Up',
submitText: 'Signing up…',
onPressed: submit,
)
When onPressed is null, the button walks up the widget tree to find the nearest matching BlocxFormWidgetState and calls its submit() method. If no matching ancestor exists, it throws a clear FlutterError.
Validation errors do not disable the button by default. This is intentional for submit-time validation flows. Invalid submission is blocked inside BlocxFormBloc, not by permanently disabling the button.
Set disableWhenInvalid: true only when you explicitly want validation errors to disable the button.
| Parameter | Default | Description |
|---|---|---|
state |
— | Typed BlocxFormState<F, E>. |
buttonText |
— | Label while idle. |
submitText |
— | Label while submitting. |
type |
filled |
Button visual style. |
onPressed |
null |
Submit callback. Falls back to nearest form state. |
disableWhenInvalid |
false |
Disable button when validation errors exist. |
spacing |
8.0 |
Space between loading indicator and label. |
loadingIndicatorBuilder |
null |
Custom loading indicator. |
style |
null |
Unified button style override. |
labelTextStyle |
null |
Text style for button label. |
RegisterButtonType:
| Value | Renders as |
|---|---|
filled |
FilledButton. |
elevated |
ElevatedButton. |
outlined |
OutlinedButton. |
text |
TextButton. |
other |
Custom — override buildOtherButton() in a subclass. |
BlocxFormButtonRow #
A horizontal row with a primary submit button on the left and a secondary button on the right. The submit button is a BlocxFormRegisterButton; the secondary button defaults to Navigator.maybePop().
BlocxFormButtonRow<SignUpForm, void, SignUpField>(
formState: state,
registerText: 'Create account',
registerSubmittingText: 'Creating…',
onRegisterPressed: submit,
secondButtonText: 'Back',
disablePopWhileSubmitting: true,
)
By default, validation errors do not disable the submit button. This prevents FormValidationMode.onSubmit forms from getting stuck after the first failed submit. The form bloc remains the final authority: it validates on submit and blocks the use case when the form is invalid.
Set disableRegisterWhenInvalid: true only when your validation mode clears errors while editing, such as FormValidationMode.onUserInteraction or FormValidationMode.always.
| Parameter | Default | Description |
|---|---|---|
formState |
— | Typed BlocxFormState<F, E> passed to the register button. |
registerText |
— | Submit button label when idle. |
registerSubmittingText |
— | Submit button label while submitting. |
registerType |
filled |
Visual variant of the submit button. |
secondButtonText |
'Cancel' |
Secondary button label. |
spacing |
12.0 |
Horizontal gap between buttons. |
height |
40.0 |
Row height. |
expandEqually |
true |
Both buttons fill equal width. |
disablePopWhileSubmitting |
false |
Disable secondary button during submission. |
disableRegisterWhenInvalid |
false |
Disable submit button when validation errors exist. |
onRegisterPressed |
null |
Override submit action. |
onSecondButtonPressed |
null |
Override secondary action. Defaults to Navigator.maybePop(). |
Screen Management #
BlocxScreenManagerState #
BlocxScreenManagerState<T> is the base State for any screen that is driven by a ScreenManagerCubit. Both BlocxCollectionWidgetState and BlocxFormWidgetState extend it, so all collection and form screens get these capabilities automatically.
ScreenManagerCubit is owned internally by BaseBloc; you never construct or pass one manually. The state accesses it via bloc.screenManagerCubit.
What it handles automatically:
| Cubit state | Action |
|---|---|
ScreenManagerCubitStateDisplaySnackbar |
Calls displaySnackBar. |
ScreenManagerCubitStateDisplaySnackbarByErrorCode |
Looks up message via loc.errorCodeMessage, then calls displaySnackBar. |
ScreenManagerCubitStateDisplayErrorPage |
Replaces screen body with errorWidget. |
ScreenManagerCubitStateDisplayErrorPageByErrorCode |
Replaces screen body with errorWidgetByErrorCode. |
ScreenManagerCubitStatePop |
Calls Navigator.maybePop(). |
Override points:
| Member | Description |
|---|---|
managerCubit |
Required. Return bloc.screenManagerCubit. Already implemented in collection and form states. |
mainWidget(context, state) |
Required. Primary screen content when no error-page state is active. |
wrapInScaffold |
When true, body is wrapped by scaffoldWidget. Default: false. |
scaffoldWidget(context, body) |
Must be overridden when wrapInScaffold is true. |
decorateScaffold(scaffold) |
Wrap the scaffold with extra widgets, such as PopScope. |
errorWidget(context, state) |
Full-page error UI for ScreenManagerCubitStateDisplayErrorPage. |
errorWidgetByErrorCode(context, state) |
Full-page error UI for ScreenManagerCubitStateDisplayErrorPageByErrorCode. |
displaySnackBar(context, message, title, type) |
Override to customise snackbar presentation. |
BlocxErrorWidget #
A ready-made full-page error card. Displays a title, message, short error summary, optional collapsible stack trace panel, and action buttons.
BlocxErrorWidget.fromState(
state,
onRetry: () {
// Retry action.
},
)
BlocxErrorWidget(
error: ReadableError(
title: 'Not Found',
message: 'Resource could not be loaded.',
),
onRetry: () {},
onReport: () {},
expandDetails: false,
)
The copy-details button uses loc.copyDetails. The close button uses loc.close. Retry and report buttons are shown only when the corresponding callbacks are non-null.
Shared Utilities #
ConfirmActionWidget #
A pre-built confirmation bottom sheet used internally by BlocxCollectionItem before deletion, and available for direct use anywhere.
final confirmed = await ConfirmActionWidget.show(
context,
options: ConfirmActionOptions(
title: 'Delete account',
question: 'This action is permanent and cannot be undone.',
confirmText: 'Delete',
cancelText: 'Cancel',
imageUrl: user.avatarUrl,
requireTyping: true,
deleteWord: 'DELETE',
),
);
if (confirmed == true) {
// Proceed.
}
ConfirmActionOptions fields:
| Field | Default | Description |
|---|---|---|
title |
'Delete item' |
Sheet title. |
question |
'This action cannot be undone. Are you sure?' |
Body text. |
confirmText |
'Delete' |
Confirm button label. |
cancelText |
'Cancel' |
Cancel button label. |
icon |
Icons.delete_outline |
Fallback icon when no imageUrl. |
imageUrl |
null |
Network image replacing the icon. |
requireTyping |
false |
Show a text field the user must match. |
deleteWord |
'DELETE' |
Word required when requireTyping is true. |
BlocxStatelessWidget & BlocXWidgetState #
Lightweight base classes that expose common Flutter helpers as named getters.
BlocxStatelessWidget provides:
theme(context)textTheme(context)colorScheme(context)
BlocXWidgetState<W> provides:
themetextThemecolorSchemewidthheight
Both BlocxCollectionWidgetState and BlocxFormWidgetState extend BlocXWidgetState, so these getters are available in every screen state class.
Localization #
BlocXLocalizations provides every framework string used by blocx_core and flutter_blocx. A default English implementation is available automatically, so setup is optional. Register a custom implementation when you need localization or app-specific copy.
Commonly visible strings include:
| Member | Used by |
|---|---|
loadingText |
Default collection loading UI |
emptyListText |
Default collection empty UI |
tryAgain |
Error and retry UI |
report |
Error reporting action |
close |
Close action on error widgets |
copyDetails |
Copy-details action on error widgets |
errorDetailsCopied |
Snackbar after copying details |
somethingWentWrong |
Generic error fallback |
Validator and form-related strings include:
| Member | Used by |
|---|---|
thisFieldIsRequired |
Required field validators |
invalidEmail |
Email validator |
onlyNumbersAllowed |
Numeric string validator |
onlyAlphanumericAllowed |
Alphanumeric validator |
invalidUrl |
URL validator |
valuesDoNotMatch |
Match validator |
invalidPhoneNumber |
Phone validators |
selectedItemsMustBeUnique |
List uniqueness validator |
minLengthError(...) |
String minimum length validator |
maxLengthError(...) |
String maximum length validator |
lengthRangeError(...) |
String length range validator |
minValueError(...) |
Numeric minimum validator |
maxValueError(...) |
Numeric maximum validator |
numberRangeError(...) |
Numeric range validator |
minDateError(...) |
Date minimum validator |
maxDateError(...) |
Date maximum validator |
dateRangeError(...) |
Date range validator |
fileSizeMustBeSmallerThan(...) |
File size validator |
Error codes to handle in errorCodeMessage:
| Code | When it appears |
|---|---|
BlocXErrorCode.checkingUniqueValue |
While an async uniqueness check is in progress. |
BlocXErrorCode.valueNotAvailable |
When a uniqueness check fails. |
BlocXErrorCode.errorGettingInitialFormData |
When BlocxFormInfoFetcherMixin fails to load required data. |
BlocXErrorCode.fieldCannotBeEmpty |
When a required field is submitted empty. |
BlocXErrorCode.unknown |
Fallback for unclassified errors. |
Quickstart: Collection Screen #
The following example wires up a complete user list with infinite scroll, refresh, search, selection, highlight, and deletion.
1. Entity from blocx_core #
import 'package:blocx_core/blocx_core.dart';
class User extends BlocxBaseEntity {
final String id;
final String displayName;
final String email;
final String? avatarUrl;
const User({
required this.id,
required this.displayName,
required this.email,
this.avatarUrl,
});
@override
String get identifier => id;
}
2. Use cases from blocx_core #
import 'package:blocx_core/blocx_core.dart';
import 'package:blocx_core/list_bloc.dart';
class FetchUsersUseCase extends BlocxPaginatedUseCase<BlocxPaginatedInput, User> {
final UserRepository repo;
FetchUsersUseCase(this.repo);
@override
Future<BlocxUseCaseResult<BlocxPage<User>>> perform(
BlocxPaginatedInput input,
) async {
final users = await repo.fetchPage(
limit: input.limit,
offset: input.offset,
);
return successResult(items: users, input: input);
}
}
class SearchUsersUseCase extends BlocxSearchUseCase<BlocxSearchInput, User> {
final UserRepository repo;
SearchUsersUseCase(this.repo);
@override
Future<BlocxUseCaseResult<BlocxPage<User>>> perform(
BlocxSearchInput input,
) async {
final users = await repo.search(
query: input.searchText,
limit: input.limit,
offset: input.offset,
);
return successResult(items: users, input: input);
}
}
class DeleteUserUseCase extends BlocxBaseUseCase<String, bool> {
final UserRepository repo;
DeleteUserUseCase(this.repo);
@override
Future<BlocxUseCaseResult<bool>> perform(String id) async {
await repo.delete(id);
return success(true);
}
}
3. Collection bloc from blocx_core #
import 'package:blocx_core/blocx_core.dart';
import 'package:blocx_core/list_bloc.dart';
class UsersBloc extends BlocxCollectionBloc<User, void>
with
BlocxCollectionInfiniteMixin<User, void>,
BlocxCollectionRefreshableMixin<User, void>,
BlocxCollectionSearchableMixin<User, void>,
BlocxCollectionSelectableMixin<User, void>,
BlocxCollectionHighlightableMixin<User, void>,
BlocxCollectionDeletableMixin<User, void> {
final UserRepository repo;
UsersBloc(this.repo) : super();
@override
BlocxPaginatedUseCaseTask<BlocxPaginatedInput, User>? get paginationTask {
return BlocxPaginatedUseCaseTask<BlocxPaginatedInput, User>(
useCase: FetchUsersUseCase(repo),
inputBuilder: (offset, limit) {
return BlocxPaginatedInput(
limit: limit,
offset: offset,
);
},
);
}
@override
BlocxPaginatedUseCaseTask<BlocxSearchInput, User>? get searchUseCaseTask {
return BlocxPaginatedUseCaseTask<BlocxSearchInput, User>(
useCase: SearchUsersUseCase(repo),
inputBuilder: (offset, limit) {
return BlocxSearchInput(
searchText: searchText,
limit: limit,
offset: offset,
);
},
);
}
@override
BlocxUseCaseTask<String, bool>? deleteItemTask(User item) {
return BlocxUseCaseTask<String, bool>(
useCase: DeleteUserUseCase(repo),
inputBuilder: () => item.id,
);
}
@override
bool get isSingleSelect => false;
}
4. Collection screen from flutter_blocx #
import 'package:blocx_core/list_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blocx/list_widget.dart';
class UsersScreen extends BlocxCollectionWidget<void> {
const UsersScreen({super.key});
@override
State<UsersScreen> createState() => _UsersScreenState();
}
class _UsersScreenState
extends BlocxCollectionWidgetState<UsersScreen, User, void> {
final _searchController = TextEditingController();
@override
BlocxCollectionBloc<User, void> get generateBloc {
return UsersBloc(UserRepository());
}
@override
Widget? topWidget(BuildContext context, BlocxCollectionState<User> state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: BlocxSearchField<User, void>(
controller: _searchController,
options: const BlocxSearchFieldOptions(
hintText: 'Search users…',
),
),
);
}
@override
Widget itemBuilder(BuildContext context, User item) {
return UserCard(item: item);
}
@override
bool get wrapInScaffold => true;
@override
Widget scaffoldWidget(BuildContext context, Widget body) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: body,
);
}
@override
CollectionSettings get settings {
return CollectionSettings(
type: CollectionWidgetStateType.animatedList,
options: AnimatedInfiniteListOptions(),
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
5. Collection item from flutter_blocx #
class UserCard extends BlocxCollectionItem<User, void> {
const UserCard({super.key, required super.item});
@override
Widget buildContent(BuildContext context, User item) {
return ListTile(
leading: CircleAvatar(
child: Text(item.displayName[0]),
),
title: Text(item.displayName),
subtitle: Text(item.email),
selected: isSelected(context),
onTap: () => toggleSelection(context),
onLongPress: () => removeItem(context),
);
}
@override
ConfirmActionOptions get confirmDeleteOptions {
return ConfirmActionOptions(
title: 'Remove ${item.displayName}',
question: 'Are you sure?',
imageUrl: item.avatarUrl,
);
}
}
Quickstart: Form Screen #
1. Field enum, form entity, and validator from blocx_core #
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 updateByKey(SignUpField key, dynamic value) {
return switch (key) {
SignUpField.email => copyWith(email: value as String),
SignUpField.password => copyWith(password: value as String),
SignUpField.confirmPassword => copyWith(confirmPassword: value as String),
};
}
@override
dynamic getValueByKey(SignUpField key) {
return switch (key) {
SignUpField.email => email,
SignUpField.password => password,
SignUpField.confirmPassword => confirmPassword,
};
}
SignUpForm copyWith({
String? email,
String? password,
String? confirmPassword,
}) {
return SignUpForm(
email: email ?? this.email,
password: password ?? this.password,
confirmPassword: confirmPassword ?? this.confirmPassword,
);
}
@override
String get identifier => 'sign_up_form';
}
class SignUpValidator extends BlocxFormValidator<SignUpForm, SignUpField> {
@override
List<SignUpField> formKeys() {
return SignUpField.values;
}
@override
List<BlocxFieldValidator<SignUpForm, SignUpField, dynamic>>
getValidatorsByKey(SignUpForm formData, SignUpField key) {
return switch (key) {
SignUpField.email => [
BlocxStringRequiredValidator<SignUpForm, SignUpField>(),
BlocxStringEmailValidator<SignUpForm, SignUpField>(),
],
SignUpField.password => [
BlocxStringRequiredValidator<SignUpForm, SignUpField>(),
BlocxStringMinLengthValidator<SignUpForm, SignUpField>(8),
],
SignUpField.confirmPassword => [
BlocxStringRequiredValidator<SignUpForm, SignUpField>(),
BlocxStringMatchValidator<SignUpForm, SignUpField>(
SignUpField.password,
),
],
};
}
}
2. Form bloc from blocx_core #
import 'package:blocx_core/blocx_core.dart';
import 'package:blocx_core/form_bloc.dart';
class SignUpBloc extends BlocxFormBloc<SignUpForm, void, SignUpField>
with BlocxFormValidationMixin<SignUpForm, void, SignUpField> {
SignUpBloc() : super(const SignUpForm());
@override
BlocxFormValidator<SignUpForm, SignUpField> get validator {
return SignUpValidator();
}
@override
List<SignUpField> get formKeysList {
return SignUpField.values;
}
@override
FormValidationMode get formValidationMode {
return FormValidationMode.onSubmit;
}
@override
BlocxUseCaseTask<CreateAccountInput, AccountResponse> get submitUseCaseTask {
return BlocxUseCaseTask<CreateAccountInput, AccountResponse>(
useCase: CreateAccountUseCase(),
inputBuilder: () {
return CreateAccountInput(
email: formData.email,
password: formData.password,
);
},
);
}
}
3. Form screen from flutter_blocx #
import 'package:blocx_core/form_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_blocx/form_widget.dart';
class SignUpScreen extends BlocxFormWidget<void> {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState
extends BlocxFormWidgetState<SignUpScreen, SignUpForm, void, SignUpField> {
@override
BlocxFormBloc<SignUpForm, void, SignUpField> generateBloc() {
return SignUpBloc();
}
@override
List<SignUpField> get keys {
return SignUpField.values;
}
@override
Widget formWidget(
BuildContext context,
BlocxFormState<SignUpForm, SignUpField> state,
) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
textField(
SignUpField.email,
type: TextFieldType.outlined,
options: const BlocXTextFieldOptions(
labelText: 'Email address',
keyboardType: TextInputType.emailAddress,
),
),
SizedBox(height: formVerticalSpacing),
textField(
SignUpField.password,
type: TextFieldType.outlined,
options: const BlocXTextFieldOptions(
labelText: 'Password',
obscureText: true,
),
),
SizedBox(height: formVerticalSpacing),
textField(
SignUpField.confirmPassword,
type: TextFieldType.outlined,
options: const BlocXTextFieldOptions(
labelText: 'Confirm password',
obscureText: true,
),
),
const SizedBox(height: 32),
BlocxFormButtonRow<SignUpForm, void, SignUpField>(
formState: state,
registerText: 'Create account',
registerSubmittingText: 'Creating…',
onRegisterPressed: submit,
secondButtonText: 'Back',
disableRegisterWhenInvalid: false,
),
],
),
);
}
@override
void onFormSubmitted(
BlocxFormStateFormSubmitted<SignUpForm, SignUpField> state,
) {
Navigator.of(context).pushReplacementNamed('/home');
}
}
Contributing #
- Run
dart format .orflutter format .before committing. - Ensure
flutter analyzereports no issues. - All public APIs must include dartdoc comments.
- Add widget tests for new collection or form widgets.
- Run
flutter testto verify the full suite passes. - Keep pull requests focused: one feature or fix per PR.
License #
This project is licensed under the MIT License. See the LICENSE file at the repository root for details.