iws_form_bloc 5.0.1
iws_form_bloc: ^5.0.1 copied to clipboard
Simplified creation of forms and listings using the BLoC pattern.
IWS Form BLoC Package #
A comprehensive Flutter package that simplifies the creation of forms and data listings using the BLoC pattern. It provides a powerful, type-safe, and reactive approach to building forms with built-in validation, state management, and HTTP integration.
Features #
- Type-safe form fields: Text, password, boolean, select, multi-select, radio group, slider, date, and more
- Built-in validation: Pre-built validators for common use cases (email, password, required, etc.)
- Cross-field validation: Validate fields relative to each other (password match, date ranges, etc.)
- Validation debounce: Throttle validation during rapid typing with configurable delay
- Reactive forms: Real-time validation and state updates using BLoC pattern
- Internationalization (i18n): Built-in English and Spanish support; fully customizable via
FormBlocLocalizations - Form reset: Reset all fields to initial state with
resetForm() - HTTP integration: Seamless integration with REST APIs through IWS HTTP
- HTTP caching: Built-in caching support for improved performance and offline capabilities
- Paginated lists: Built-in support for paginated data with infinite scrolling and configurable pagination behavior
- Advanced pagination:
PaginatedListBlocwith granular navigation control (next, previous, goto page), built-in validation, and bidirectional page navigation - Search functionality: Advanced search capabilities with pagination and observable query parameters
- Custom field builders: Ready-to-use UI components with optimized
buildWhenfor all field types - Multi-step forms: Support for wizard-style forms with step validation
- Error handling: Comprehensive error handling with field-specific error messages
- Loading states: Built-in loading indicators and state management
- Performance: O(1) field lookup via HashMap; builders only rebuild when their specific field changes
Installation #
Add this to your package's pubspec.yaml file:
dependencies:
iws_form_bloc: ^latest_version
🚀 What's New in v4.0.0 #
Internationalization (i18n) #
Validation errors and UI messages are now fully localizable. Default is English.
Option A — fixed language at startup:
void main() {
FormBlocConfig.setLocalizations(const FormBlocLocalizationsEs());
runApp(const MyApp());
}
Option B — reactive to device locale via MaterialApp.localizationsDelegates:
MaterialApp(
localizationsDelegates: [
FormBlocLocalizationsDelegate.delegate, // auto en/es; pass custom map to add more
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('en'), Locale('es')],
)
Custom language: implement the FormBlocLocalizations abstract class.
Form Reset #
bloc.resetForm(); // Reset all fields to initial value
bloc.resetForm(keepValues: true); // Reset errors only, keep current values
Cross-Field Validation #
Override crossValidators in your FormBloc for password-match, date-range checks, etc.:
class RegisterBloc extends FormBloc {
final password = TextFieldBloc(name: 'password');
final confirmPassword = TextFieldBloc(name: 'confirm_password');
@override
List<Map<String, String?> Function(FormBlocState)> get crossValidators => [
(state) {
final pwd = state.fieldByName('password');
final confirm = state.fieldByName('confirm_password');
if (pwd?.value != confirm?.value) {
return {'confirm_password': 'Passwords do not match'};
}
return {};
}
];
}
Validation Debounce #
class MyFormBloc extends FormBloc {
MyFormBloc() : super(validationDebounce: const Duration(milliseconds: 300));
}
New Widgets #
RadioGroupFieldBlocBuilder: RendersRadioListTilewidgets from aSelectFieldBlocSliderFieldBlocBuilder: Renders aSliderfrom anInputFieldBloc<double, D>
Async Item Loading for SelectFieldBloc #
final roleField = SelectFieldBloc<Role, dynamic>(
name: 'role',
itemsLoader: () async => await api.getRoles(),
);
// Trigger loading:
await bloc.roleField.loadItems();
Performance Improvements #
- All 13 field builders use
buildWhen— only the changed field triggers a rebuild FormBlocState.fieldByName(name)uses O(1) HashMap lookup instead of linear search
Removed in v5.0.0 #
FormBlocConsumer(loadFaild: ...)→ removed; useloadFailed:insteadConfirmPasswordFieldBloc→ removed; useTextFieldBlocwith crossValidators or custom validator
Quick Start #
1. Import the package #
import 'package:iws_form_bloc/iws_form_bloc.dart';
Form Implementation #
Creating a Form BLoC #
class LoginBloc extends FormBloc {
final authenticationProvider = IwsHttp.setup(authority: 'your-api.com');
final email = TextFieldBloc(name: 'email', validators: [Validators.email, Validators.required]);
final password = TextFieldBloc(name: 'password', validators: [Validators.password, Validators.required]);
final rememberMe = BooleanFieldBloc(name: 'remember_me');
LoginBloc() {
addFieldBlocs(fieldBlocs: [email, password, rememberMe]);
}
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
try {
final response = await authenticationProvider.post(
path: 'auth/login',
body: {
'email': email.value,
'password': password.value,
'remember_me': rememberMe.value ?? false,
}
);
emitSuccessCreation(emit, successResponse: response);
} on ApiException catch (e) {
emitFailure(emit, failureResponse: FormErrorModel(message: e.error, exception: e));
}
}
}
Using Form Widgets #
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocProvider(
create: (context) => LoginBloc(),
child: Builder(
builder: (context) {
final bloc = BlocProvider.of<LoginBloc>(context);
return FormBlocConsumer<LoginBloc>(
loadFailed: const Center(child: Text('Error loading form')),
onFailure: (context, state) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.failureResponse?.message ?? 'Login failed'))
);
},
onSuccess: (context, state) {
Navigator.pushReplacementNamed(context, '/home');
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Email field
TextFieldBlocBuilder<LoginBloc>(
bloc: bloc,
blocField: bloc.email,
inputType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// Password field
PasswordFieldBlocBuilder<LoginBloc>(
bloc: bloc,
blocField: bloc.password,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
border: OutlineInputBorder(),
),
onSubmitted: (value) => bloc.submit(),
),
const SizedBox(height: 16),
// Remember me checkbox
CheckboxFieldBlocBuilder<LoginBloc>(
bloc: bloc,
blocField: bloc.rememberMe,
label: 'Remember me',
),
const SizedBox(height: 24),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => bloc.submit(),
child: const Text('LOGIN'),
),
),
],
),
),
);
},
),
),
);
}
}
Field Types and Builders #
This package provides a comprehensive set of field types and corresponding widget builders for all common use cases. Each field type is designed to handle a specific data type with automatic validation, state management, and UI rendering.
Each field builder is optimized with buildWhen to ensure only the affected field rebuilds when its value changes, providing excellent performance even with complex forms.
Key Features:
- Type-safe generic builders with full IDE support
- Automatic validation and error display
- Reactive state management via BLoC
- Built-in support for async data loading
- Customizable UI through parameters
- O(1) field lookup by name via HashMap
This document organizes all available field types and their associated widgets. Each section represents a specific field bloc type and lists all compatible widgets.
TextFieldBloc #
The TextFieldBloc is the foundation for text input fields. It provides text validation, formatting, and state management.
TextFieldBlocBuilder
Basic text input widget with customizable decorations and text formatting options.
// In BLoC
final nameField = TextFieldBloc(
name: 'name',
validators: [Validators.required],
);
// In Widget
TextFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.nameField,
decoration: const InputDecoration(
labelText: 'Full Name',
hintText: 'Enter your full name',
),
maxLength: 50,
textCapitalization: TextCapitalization.words,
)
Parameters:
bloc: Reference to your FormBlocblocField: The TextFieldBloc instancedecoration: InputDecoration for stylingmaxLength: Maximum character limittextCapitalization: Text capitalization behaviorkeyboardType: Keyboard type (default: text)obscureText: Hide text input (for passwords, use PasswordFieldBlocBuilder instead)
PasswordFieldBlocBuilder
Specialized widget for password input with built-in visibility toggle.
// In BLoC
final passwordField = TextFieldBloc(
name: 'password',
validators: [Validators.password, Validators.required],
);
// In Widget
PasswordFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.passwordField,
decoration: const InputDecoration(
labelText: 'Password',
helperText: 'At least 8 characters with uppercase, lowercase and number',
),
)
Parameters:
bloc: Reference to your FormBlocblocField: The TextFieldBloc instancedecoration: InputDecoration for stylingshowPasswordIcon: Show/hide password toggle button (default: true)
InputChipTextBlocBuilder
Chip-based widget for displaying and managing a single text value using Material InputChip.
// In BLoC
final tagField = TextFieldBloc(
name: 'tag',
validators: [Validators.required],
);
// In Widget
InputChipTextBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.tagField,
label: 'Enter Tag',
addLabel: 'Add tag',
prefixIcon: Icon(Icons.label_outline),
itemBuilder: (tag) => Text(tag ?? 'No tag'),
onAdd: () {
// Show tag input dialog
},
onRemoved: (tag) {
// Handle tag removal
},
onPressed: (tag) {
// Handle tag press
},
tooltip: (tag) => 'Tag: $tag',
)
Parameters:
bloc: Reference to your FormBlocblocField: The TextFieldBloc instancelabel: Widget labeladdLabel: Label displayed in the chip when field is emptyitemBuilder: Function to build the chip label for the text valueprefixIcon: Optional icon to display before the chip contentonAdd: Callback when the add chip is pressed (when value is empty)onRemoved: Callback when the remove button is pressedonPressed: Callback when the chip is pressedshowRemoveButton: Show delete/remove button on the chip (default: true)deleteTooltipMessage: Tooltip message for the delete buttontooltip: Function to generate tooltip text for the valueenabled: Enable/disable the chip interaction (default: true)
Key Differences from InputChipBlocBuilder:
- Uses
TextFieldBlocinstead ofInputFieldBloc - Works with simple
Stringvalues instead of complex objects - Simpler integration when you need text-based chip selection
- Static
prefixIconinstead of dynamic builder function
BooleanFieldBloc #
The BooleanFieldBloc handles boolean (true/false) values with built-in validation.
CheckboxFieldBlocBuilder
Traditional checkbox widget with label and subtitle support.
// In BLoC
final agreeToTerms = BooleanFieldBloc(
name: 'agree_terms',
validators: [(value) => value == true ? null : 'You must agree to terms'],
);
// In Widget
CheckboxFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.agreeToTerms,
label: 'I agree to the Terms and Conditions',
subtitle: 'Please read our terms carefully',
)
Parameters:
bloc: Reference to your FormBlocblocField: The BooleanFieldBloc instancelabel: Main label textsubtitle: Optional secondary textenabled: Enable/disable the checkbox (default: true)
ChecksegmentFieldBlocBuilder
Modern segmented button widget offering visual true/false selection with custom labels.
// In BLoC
final notificationsField = BooleanFieldBloc(
name: 'notifications',
initialValue: true,
);
// In Widget
ChecksegmentFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.notificationsField,
trueLabel: 'Enabled',
falseLabel: 'Disabled',
onChanged: (value) => print('Notifications: $value'),
)
Parameters:
bloc: Reference to your FormBlocblocField: The BooleanFieldBloc instancetrueLabel: Label displayed for the true/enabled segmentfalseLabel: Label displayed for the false/disabled segmentenabled: Enable or disable the segmented button (default: true)onChanged: Optional callback triggered when the value changes
SelectFieldBloc<T, D> #
The SelectFieldBloc manages single-value selection from a list of items. Ideal for dropdowns, radio groups, and selection dialogs.
DropdownFieldBlocBuilder
Classic dropdown/select widget for choosing one item from a list.
// In BLoC
final countryField = SelectFieldBloc<Country, dynamic>(
name: 'country',
validators: [Validators.required],
items: countryList,
);
// In Widget
DropdownFieldBlocBuilder<MyFormBloc, Country>(
bloc: bloc,
blocField: bloc.countryField,
label: 'Select Country',
itemBuilder: (country) => Text(country.name),
showEmptyItem: true,
emptyLabel: 'Choose a country...',
)
Parameters:
bloc: Reference to your FormBlocblocField: The SelectFieldBloc instancelabel: Widget labelitemBuilder: Function to build each item widgetshowEmptyItem: Show empty/null option (default: false)emptyLabel: Label for the empty option
DropdownFieldBlocBuilder with Async Items
Load items dynamically from an API or async source.
// In BLoC — items loaded lazily from API
final countryField = SelectFieldBloc<Country, dynamic>(
name: 'country',
validators: [Validators.required],
itemsLoader: () async => await apiProvider.getCountries(),
);
CountryFormBloc() {
addFieldBlocs(fieldBlocs: [countryField]);
// Load items when the form initializes
countryField.loadItems().then((_) => emit(state.copyWith()));
}
// In Widget — show loading indicator while items load
DropdownFieldBlocBuilder<MyFormBloc, Country>(
bloc: bloc,
blocField: bloc.countryField,
label: 'Country',
showLoading: true,
itemBuilder: (country) => Text(country.name),
)
Parameters:
showLoading: Display loading indicator while items are being fetched (default: true)loadingWidget: Custom widget to show during loading
RadioGroupFieldBlocBuilder
Radio button group widget for visually distinct selection options.
// In BLoC
final shippingMethod = SelectFieldBloc<String, dynamic>(
name: 'shipping_method',
validators: [Validators.required],
items: ['Standard', 'Express', 'Overnight'],
);
// In Widget
RadioGroupFieldBlocBuilder<MyFormBloc, String>(
bloc: bloc,
blocField: bloc.shippingMethod,
label: 'Shipping Method',
itemBuilder: (context, value) => Text(value),
)
Parameters:
bloc: Reference to your FormBlocblocField: The SelectFieldBloc instancelabel: Widget labelitemBuilder: Function to build each radio option labelenabled: Enable/disable radio group (default: true)
ColorSelectBlocBuilder
Specialized dropdown widget for selecting colors with visual color preview circles.
// In BLoC
final themeColorField = SelectFieldBloc<ColorType, dynamic>(
name: 'theme_color',
validators: [Validators.required],
items: [
ColorType(name: 'Red', color: Colors.red),
ColorType(name: 'Blue', color: Colors.blue),
ColorType(name: 'Green', color: Colors.green),
],
);
// In Widget
ColorSelectBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.themeColorField,
label: 'Select Theme Color',
prefixIcon: Icon(Icons.palette),
showEmptyItem: true,
onChanged: (color) => print('Selected color: ${color?.name}'),
)
Parameters:
bloc: Reference to your FormBlocblocField: The SelectFieldBloc<ColorType, dynamic> instancelabel: Widget labelprefixIcon: Optional icon to display before the dropdownshowEmptyItem: Show empty/null option (default: true)border: Custom InputBorder for the dropdownreadOnly: Disable color selection (default: false)onChanged: Optional callback triggered when color is selected
Features:
- Color preview displayed as circular avatars next to color names
- Built on top of
DropdownFieldBlocBuilderfor consistency - Automatic color visualization without custom itemBuilder
- Full support for validation and error display
- Responsive to device theme and styling
ColorType Model:
The widget expects items of type ColorType which includes:
class ColorType {
final String name;
final Color color;
ColorType({required this.name, required this.color});
}
MultiSelectFieldBloc<T, D> #
The MultiSelectFieldBloc handles selection of multiple items from a list.
FilterChipFieldBlocBuilder
Chip-based widget for selecting multiple items with visual feedback.
// In BLoC
final skillsField = MultiSelectFieldBloc<String, dynamic>(
name: 'skills',
items: ['Flutter', 'Dart', 'React', 'Node.js', 'Python'],
);
// In Widget
FilterChipFieldBlocBuilder<MyFormBloc, String>(
bloc: bloc,
blocField: bloc.skillsField,
label: 'Select your skills',
itemBuilder: (skill) => Text(skill),
)
Parameters:
bloc: Reference to your FormBlocblocField: The MultiSelectFieldBloc instancelabel: Widget labelitemBuilder: Function to build each chip labelonSelected: Optional callback when item is selected/deselectedspacing: Space between chips (default: 8.0)runSpacing: Vertical space between rows (default: 8.0)
CheckboxListFieldBlocBuilder
Checkbox list widget for selecting multiple items with optional subtitles per item.
// In BLoC
final permissionsField = MultiSelectFieldBloc<Permission, dynamic>(
name: 'permissions',
items: [
Permission(id: 1, name: 'Read', description: 'View content'),
Permission(id: 2, name: 'Write', description: 'Create and edit'),
Permission(id: 3, name: 'Delete', description: 'Remove content'),
],
);
// In Widget
CheckboxListFieldBlocBuilder<MyFormBloc, Permission>(
bloc: bloc,
blocField: bloc.permissionsField,
label: 'Select Permissions',
itemTitleBuilder: (permission) => Text(permission.name),
itemSubtitleBuilder: (permission) => Text(permission.description),
onChanged: (selected) => print('Selected: ${selected.length} permissions'),
readOnly: false,
)
Parameters:
bloc: Reference to your FormBlocblocField: The MultiSelectFieldBloc<T, dynamic> instancelabel: Section label displayed above the listitemTitleBuilder: Function to build the title widget for each itemitemSubtitleBuilder: Optional function to build subtitle for each itemonChanged: Optional callback triggered when selection changesenabled: Enable/disable checkbox interaction (default: true)readOnly: Disable modification but allow viewing (default: false)
Features:
- Displays items as a vertical list of
CheckboxListTile - Support for optional subtitles under each item
- Error state visualization with visual feedback
- Full validation support
- Automatic state management with BLoC
- Responsive to form status changes
Key Differences from FilterChipFieldBlocBuilder:
- Vertical list layout instead of horizontal chips/wrapping
- Support for subtitles on each item
- CheckboxListTile styling for consistent Material Design
- Better for longer lists with descriptive text
- More touch-friendly for mobile interfaces
InputFieldBloc<T, D> #
The InputFieldBloc is a generic field for complex value types. It's used for dates, numbers, objects, and other non-simple types.
DateFieldBlocBuilder
Date picker widget with customizable date format and range.
// In BLoC
final birthDateField = InputFieldBloc<DateTime?, dynamic>(
name: 'birth_date',
validators: [Validators.required],
);
// In Widget
DateFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.birthDateField,
label: 'Birth Date',
firstDate: DateTime(1950),
lastDate: DateTime.now(),
dateFormat: (date) => DateFormat('dd/MM/yyyy').format(date),
showClearIcon: true,
readOnly: false,
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<DateTime?, D> instancelabel: Widget labelfirstDate: Earliest selectable datelastDate: Latest selectable datedateFormat: Function to format date for displayshowClearIcon: Display clear button (default: true)readOnly: Disable date editing (default: false)
CalendarFieldBlocBuilder
Full calendar widget for date selection with visual calendar view.
// In BLoC
final eventDateField = InputFieldBloc<DateTime?, dynamic>(
name: 'event_date',
);
// In Widget
CalendarFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.eventDateField,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
dateFormat: (date) => DateFormat('EEEE, MMMM d, y').format(date),
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<DateTime?, D> instancefirstDate: Earliest selectable datelastDate: Latest selectable datedateFormat: Function to format date for displayshowTodayButton: Show "Today" button (default: true)
TimeFieldBlocBuilder
Time picker widget with customizable formatting and optional clear button.
// In BLoC
final meetingTimeField = InputFieldBloc<TimeOfDay?, dynamic>(
name: 'meeting_time',
validators: [Validators.required],
);
// In Widget
TimeFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.meetingTimeField,
decoration: const InputDecoration(
labelText: 'Meeting Time',
hintText: 'Select a time',
prefixIcon: Icon(Icons.access_time),
),
showClearIcon: true,
onChanged: (time) => print('Selected time: $time'),
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<TimeOfDay?, D> instancedecoration: InputDecoration for styling (optional)showClearIcon: Display clear/reset button (default: false)onChanged: Optional callback triggered when time is selected
Features:
- Read-only text field displaying formatted time
- Material
showTimePickerdialog integration - Automatic time formatting using device locale
- Optional clear button to reset the time
- Error display support with validation messages
SliderFieldBlocBuilder
Slider widget for numeric value selection with optional labels and divisions.
// In BLoC
final volumeField = InputFieldBloc<double, void>(
name: 'volume',
initialValue: 50.0,
);
// In Widget
SliderFieldBlocBuilder<MyFormBloc, void>(
bloc: bloc,
blocField: bloc.volumeField,
label: 'Volume',
min: 0,
max: 100,
divisions: 100,
thumbLabelBuilder: (value) => '${value.toInt()}%',
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<double, D> instancelabel: Widget labelmin: Minimum slider value (default: 0.0)max: Maximum slider value (default: 100.0)divisions: Number of discrete divisionsthumbLabelBuilder: Function to format the thumb labelactiveColor: Color of the active slider portioninactiveColor: Color of the inactive slider portion
ListTileSelectBlocBuilder
List tile-based widget for selecting a single complex object.
// In BLoC
final selectedItemField = InputFieldBloc<Item?, dynamic>(
name: 'selected_item',
validators: [Validators.required],
);
// In Widget
ListTileSelectBlocBuilder<MyFormBloc, Item>(
bloc: bloc,
blocField: bloc.selectedItemField,
label: 'Select Item',
addLabel: 'Add an item',
prefixIconBuilder: (item) => Icon(Icons.category),
itemBuilder: (item) => Text(item.name),
onAdd: () {
// Logic to select item
},
onRemoved: (item) {
// Handle item removal if needed
},
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<T?, D> instancelabel: Widget labeladdLabel: Label for the add/select actionitemBuilder: Function to build the item displayprefixIconBuilder: Optional function to build an icon before the itemonAdd: Callback when add button is pressedonRemoved: Callback when item is removed
InputChipBlocBuilder
Chip-based widget for selecting or displaying a single item using Material InputChip.
// In BLoC
final selectedItemField = InputFieldBloc<Item?, dynamic>(
name: 'selected_item',
validators: [Validators.required],
);
// In Widget
InputChipBlocBuilder<MyFormBloc, Item>(
bloc: bloc,
blocField: bloc.selectedItemField,
label: 'Select Item',
addLabel: 'Add item',
prefixIconBuilder: (item) => item != null ? Icon(Icons.check) : Icon(Icons.add),
itemBuilder: (item) => Text(item.name),
onAdd: () {
// Show item picker dialog
},
onRemoved: (item) {
// Handle item removal
},
onPressed: (item) {
// Handle item press
},
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<T?, D> instancelabel: Widget labeladdLabel: Label displayed in the chip when no item is selecteditemBuilder: Function to build the chip label for the selected itemprefixIconBuilder: Function to build an icon before the chip contentonAdd: Callback when the add chip is pressed (when value is null)onRemoved: Callback when the remove button is pressedonPressed: Callback when the chip is pressedshowRemoveButton: Show delete/remove button on the chip (default: true)deleteTooltipMessage: Tooltip message for the delete buttontooltip: Function to generate tooltip text for the selected itemenabled: Enable/disable the chip interaction (default: true)
InputChipMultiBlocBuilder
Multi-chip widget for selecting or displaying multiple items using Material InputChip.
// In BLoC
final selectedTagsField = InputFieldBloc<List<Tag>, dynamic>(
name: 'selected_tags',
validators: [Validators.required],
initialValue: [],
);
// In Widget
InputChipMultiBlocBuilder<MyFormBloc, Tag>(
bloc: bloc,
blocField: bloc.selectedTagsField,
label: 'Select Tags',
prefixIconBuilder: (tag) => tag != null ? Icon(Icons.label) : Icon(Icons.add),
itemBuilder: (tag) => Text(tag.name),
isEqual: (tag1, tag2) => tag1.id == tag2.id,
onAdd: () {
// Show tag picker dialog
},
onRemoved: (tag) {
// Handle tag removal
},
onPressed: (tag) {
// Handle tag press
},
tooltip: (tag) => 'Tag: ${tag.name}',
)
Parameters:
bloc: Reference to your FormBlocblocField: The InputFieldBloc<Listlabel: Widget labelitemBuilder: Function to build the chip label for each selected itemprefixIconBuilder: Function to build an icon before the chip contentisEqual: Required function to compare items for equality (used when removing items)onAdd: Callback when the add button is pressedonRemoved: Callback when a chip's remove button is pressedonPressed: Callback when a chip is presseddeleteTooltipMessage: Tooltip message for the delete buttontooltip: Function to generate tooltip text for each itemenabled: Enable/disable chip interaction (default: true)
Key Differences from InputChipBlocBuilder:
- Handles
List<T>instead of singleT?values - Displays multiple chips in a
Wraplayout for responsive multi-line display - Requires
isEqualfunction for comparing items (important for list operations) - Add button appears as trailing icon in the ListTile header
- Each chip can be individually removed
Field Builder Best Practices #
-
Type Safety: Always specify the generic types explicitly:
DropdownFieldBlocBuilder<MyFormBloc, Country>(...) -
Register Fields: Ensure all field blocs are registered with
addFieldBlocs():addFieldBlocs(fieldBlocs: [field1, field2, field3]); -
Validation: Combine field-level validation with cross-field validation:
final email = TextFieldBloc( name: 'email', validators: [Validators.required, Validators.email], ); -
Async Items: For
SelectFieldBlocwith async items, load them during bloc initialization or on demand. -
Performance: Field builders use
buildWhento optimize rebuilds. Only the changed field triggers a rebuild.
Validation #
Built-in Validators #
class Validators {
static String? required(dynamic value); // Checks if field is not empty
static String? email(String? value); // Validates email format
static String? password(String? value); // Strong password validation
static String? integer(String? value); // Integer number validation
static String? float(String? value); // Float number validation
static String? alphaDash(String? value); // Letters, numbers, dashes only
}
Custom Validators #
// Custom validator function
String? phoneValidator(String? value) {
if (value == null || value.isEmpty) return null;
if (!RegExp(r'^\+?[\d\s-()]+$').hasMatch(value)) {
return 'Please enter a valid phone number';
}
return null;
}
// Using custom validator
final phoneField = TextFieldBloc(
name: 'phone',
validators: [Validators.required, phoneValidator],
);
Internationalization (i18n) #
Validation error messages and UI strings default to English since v4.0.0. Two approaches are available to switch languages.
Option A — Fixed language at startup #
Call FormBlocConfig.setLocalizations() once in main(). Use this when the app has a fixed language regardless of the device locale.
import 'package:iws_form_bloc/iws_form_bloc.dart';
void main() {
// English (default — no configuration needed)
// FormBlocConfig.setLocalizations(const FormBlocLocalizationsEn());
// Spanish
FormBlocConfig.setLocalizations(const FormBlocLocalizationsEs());
runApp(const MyApp());
}
Option B — Reactive to device/app locale #
Add FormBlocLocalizationsDelegate to MaterialApp.localizationsDelegates. It automatically calls FormBlocConfig.setLocalizations() whenever the locale changes — no manual setup needed. Use this when the app follows the device locale or allows the user to switch language at runtime.
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:iws_form_bloc/iws_form_bloc.dart';
MaterialApp(
localizationsDelegates: [
FormBlocLocalizationsDelegate.delegate, // built-in en + es
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('en'), Locale('es')],
)
Pass a custom map to support additional languages:
FormBlocLocalizationsDelegate(
localizations: {
Locale('en'): FormBlocLocalizationsEn(),
Locale('es'): FormBlocLocalizationsEs(),
Locale('pt'): FormBlocLocalizationsPt(), // custom implementation
},
)
Custom Language #
Implement FormBlocLocalizations to provide any language:
class FormBlocLocalizationsPt extends FormBlocLocalizations {
const FormBlocLocalizationsPt();
@override String get requiredField => 'Este campo é obrigatório';
@override String get invalidEmail => 'Digite um e-mail válido';
@override String get invalidPassword => 'A senha deve ter letras maiúsculas, minúsculas e números';
@override String get passwordsDoNotMatch => 'As senhas não coincidem';
@override String get invalidInteger => 'Digite um número inteiro válido';
@override String get invalidFloat => 'Digite um número válido';
@override String get alphaDashOnly => 'Use apenas letras, números, traços e sublinhados';
@override String get pleaseReviewData => 'Revise os dados informados';
@override String get invalidCommand => 'Comando inválido';
@override String get unexpectedError => 'Erro inesperado';
}
// Option A — fixed at startup:
FormBlocConfig.setLocalizations(const FormBlocLocalizationsPt());
// Option B — via delegate:
FormBlocLocalizationsDelegate(
localizations: {Locale('pt'): const FormBlocLocalizationsPt()},
)
Cross-Field Validation #
Override the crossValidators getter in your FormBloc to validate fields relative to each other. Cross-validators run after all individual field validators pass, just before submission.
class RegisterBloc extends FormBloc {
final password = TextFieldBloc(
name: 'password',
validators: [Validators.required, Validators.password],
);
final confirmPassword = TextFieldBloc(
name: 'confirm_password',
validators: [Validators.required],
);
final startDate = InputFieldBloc<DateTime?, dynamic>(name: 'start_date');
final endDate = InputFieldBloc<DateTime?, dynamic>(name: 'end_date');
RegisterBloc() {
addFieldBlocs(fieldBlocs: [password, confirmPassword, startDate, endDate]);
}
@override
List<Map<String, String?> Function(FormBlocState)> get crossValidators => [
// Password match
(state) {
final pwd = state.fieldByName('password');
final confirm = state.fieldByName('confirm_password');
if (pwd?.value != null && pwd?.value != confirm?.value) {
return {'confirm_password': 'Passwords do not match'};
}
return {};
},
// Date range
(state) {
final start = state.fieldByName('start_date');
final end = state.fieldByName('end_date');
final startVal = start?.value as DateTime?;
final endVal = end?.value as DateTime?;
if (startVal != null && endVal != null && endVal.isBefore(startVal)) {
return {'end_date': 'End date must be after start date'};
}
return {};
},
];
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
// Cross-validators run automatically before this is called
await apiProvider.post(path: 'register', body: {
'password': password.value,
});
emitSuccessCreation(emit);
}
}
Form Reset #
Reset all fields to their initial values (empty), or reset errors while keeping current values:
// Reset everything — clears values and errors
bloc.resetForm();
// Keep values but clear errors and reset status
bloc.resetForm(keepValues: true);
In the UI you can listen with FormBlocListener and trigger reset on a button press:
OutlinedButton(
onPressed: () => bloc.resetForm(),
child: const Text('Reset'),
),
Validation Debounce #
Avoid running validators on every single keystroke by configuring a debounce duration:
class SearchFormBloc extends FormBloc {
final query = TextFieldBloc(
name: 'query',
validators: [Validators.required],
);
SearchFormBloc()
: super(validationDebounce: const Duration(milliseconds: 300)) {
addFieldBlocs(fieldBlocs: [query]);
}
@override
void onSubmitting(Emitter<FormBlocState> emit) async { /* ... */ }
}
The UI remains responsive (values update immediately) but validation only triggers after the user pauses typing.
Multi-Step Forms #
class MultiStepFormBloc extends FormBloc {
// Step 1 fields
final firstName = TextFieldBloc(name: 'first_name', validators: [Validators.required], step: 1);
final lastName = TextFieldBloc(name: 'last_name', validators: [Validators.required], step: 1);
// Step 2 fields
final email = TextFieldBloc(name: 'email', validators: [Validators.email], step: 2);
final phone = TextFieldBloc(name: 'phone', step: 2);
// Step 3 fields
final address = TextFieldBloc(name: 'address', step: 3);
final city = TextFieldBloc(name: 'city', step: 3);
MultiStepFormBloc() {
addFieldBlocs(fieldBlocs: [firstName, lastName, email, phone, address, city]);
}
bool validateCurrentStep(int step) {
return validateStep(step);
}
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
// Submit all form data
final formData = {
'first_name': firstName.value,
'last_name': lastName.value,
'email': email.value,
'phone': phone.value,
'address': address.value,
'city': city.value,
};
try {
await apiProvider.post(path: 'users', body: formData);
emitSuccessCreation(emit);
} catch (e) {
emitFailure(emit, failureResponse: e);
}
}
}
Lists and Pagination #
Basic List Implementation #
class User {
final int id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) => User(
id: json['id'],
name: json['name'],
email: json['email'],
);
static Map<String, dynamic> toJson(User user) => {
'id': user.id,
'name': user.name,
'email': user.email,
};
}
// Standard paginated list (default behavior)
class UserListBloc extends ListBloc<User> {
UserListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
usePagination: true, // Default: true, sends 'page' parameter to API
);
}
// Non-paginated list (for endpoints that return all data at once)
class AllUsersListBloc extends ListBloc<User> {
AllUsersListBloc() : super(
provider: IwsHttpModel(
path: 'users/all',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
usePagination: false, // No 'page' parameter sent to API
);
}
// List with caching support
class CachedUserListBloc extends ListBloc<User> {
CachedUserListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
cacheConfig: CacheConfig(
cache: IwsMemoryCache(), // or IwsPersistentCache()
ttl: Duration(minutes: 15), // Cache for 15 minutes
),
);
}
Pagination Control with usePagination Parameter
The ListBloc provides flexible pagination control through the usePagination parameter:
When usePagination: true (default):
- Standard pagination behavior
- Sends
pageparameter to the API - Supports infinite scrolling
- Accumulates data across pages
When usePagination: false:
- Single request for all data
- No
pageparameter sent to API - Ideal for endpoints that return complete datasets
- Still supports refresh and custom events
// Example usage for different scenarios
class PaginatedUsersBloc extends ListBloc<User> {
PaginatedUsersBloc() : super(
provider: IwsHttpModel(path: 'users', ...),
usePagination: true, // Default: sends page=1, page=2, etc.
);
}
class AllUsersBloc extends ListBloc<User> {
AllUsersBloc() : super(
provider: IwsHttpModel(path: 'users/all', ...),
usePagination: false, // No page parameter, single request
);
}
Paginated List Implementation #
For more advanced pagination control with dedicated states and custom events, use PaginatedListBloc:
class UserPaginatedListBloc extends PaginatedListBloc<User> {
UserPaginatedListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
customEvents: {
'archive': (User user) async {
// Custom archive functionality
await userRepository.archiveUser(user.id);
},
'duplicate': (User user) async {
// Custom duplicate functionality
await userRepository.duplicateUser(user);
},
},
pathParams: {'department': 'engineering'},
apiKey: true,
auth: true,
);
}
HTTP Caching Support #
Both ListBloc and PaginatedListBloc support HTTP caching through the cacheConfig parameter, providing improved performance and offline capabilities.
Note: To use caching features, import
package:iws_cache/iws_cache.dartfor cache implementations likeIwsMemoryCache()andIwsPersistentCache().
Basic Caching Configuration
// List with caching enabled
class CachedUserListBloc extends ListBloc<User> {
CachedUserListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
cacheConfig: CacheConfig(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 15), // Cache for 15 minutes
),
);
}
// Paginated list with caching
class CachedPaginatedUserListBloc extends PaginatedListBloc<User> {
CachedPaginatedUserListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
cacheConfig: CacheConfig(
cache: IwsMemoryCache(), // or IwsPersistentCache()
ttl: Duration(hours: 1), // Cache for 1 hour
),
);
}
Event-Level Cache Control
You can override cache behavior on individual events:
// ListBloc events with custom caching
bloc.add(ListFetched(
cacheConfig: CacheConfig.forceRefresh(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 5),
),
));
bloc.add(ListRefresh(
cacheConfig: null, // Skip cache for this request
));
// PaginatedListBloc events with custom caching
bloc.add(PaginatedListGoToPage(
page: 1,
cacheConfig: CacheConfig(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 30),
),
));
bloc.add(PaginatedListNextPage(
cacheConfig: CacheConfig.forceRefresh(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 10),
),
));
bloc.add(PaginatedListRefresh(
resetPage: true,
cacheConfig: CacheConfig.forceRefresh(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 15),
),
));
Cache Behavior
- Constructor-level caching: Default cache behavior for all requests
- Event-level caching: Override cache settings for specific requests
- Automatic refresh on refresh events: Refresh operations automatically force cache refresh to ensure fresh data
- TTL (Time To Live): Configurable cache expiration time
- Force refresh: Option to bypass cache and fetch fresh data
// Example of mixed caching strategies
class SmartCachedListBloc extends ListBloc<User> {
SmartCachedListBloc() : super(
provider: IwsHttpModel(...),
cacheConfig: CacheConfig(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 30), // Default 30-minute cache
),
);
// Force fresh data for critical updates
void refreshWithFreshData() {
add(ListRefresh(
cacheConfig: CacheConfig.forceRefresh(
cache: IwsMemoryCache(),
ttl: Duration(minutes: 5), // Short cache after refresh
),
));
}
// Use longer cache for background fetches
void backgroundFetch() {
add(ListFetched(
cacheConfig: CacheConfig(
cache: IwsMemoryCache(),
ttl: Duration(hours: 2), // Longer cache for background
),
));
}
}
PaginatedListBloc Features
-
Granular State Management: Dedicated statuses for different operations:
initial,success,failure,gettingdeleting,deletedfor item deletionprocessing,processingSuccessfor custom events
-
Custom Events: Define custom operations that can be performed on items:
// Trigger custom event
bloc.add(PaginatedListCustomEvent(event: 'archive', data: user));
- Advanced Configuration:
pathParams: Dynamic path parameters for API callscustomPath: Override the default endpoint pathapiKeyandauth: Authentication optionsqueryParameters: Filter and search parameters
PaginatedListBloc Events
The PaginatedListBloc provides granular navigation control with dedicated events for different pagination actions:
// Navigate to a specific page
bloc.add(PaginatedListGoToPage(page: 1)); // Go to first page
bloc.add(PaginatedListGoToPage(page: 3)); // Go to page 3
bloc.add(PaginatedListGoToPage(page: 1, queryParameters: {'search': 'john'}));
// Navigate to next page
bloc.add(PaginatedListNextPage()); // Advance to next page
bloc.add(PaginatedListNextPage(queryParameters: {'filter': 'active'}));
// Navigate to previous page
bloc.add(PaginatedListPreviousPage()); // Go back to previous page
bloc.add(PaginatedListPreviousPage(queryParameters: {'sort': 'name'}));
// Refresh data
bloc.add(PaginatedListRefresh(resetPage: true)); // Reset to page 1
bloc.add(PaginatedListRefresh(resetPage: false)); // Keep current page
bloc.add(PaginatedListRefresh(
queryParameters: {'updated': 'true'},
resetPage: true
));
// Events with custom caching
bloc.add(PaginatedListGoToPage(
page: 1,
cacheConfig: CacheConfig(cache: IwsMemoryCache(), ttl: Duration(minutes: 10)),
));
bloc.add(PaginatedListNextPage(
cacheConfig: CacheConfig.forceRefresh(cache: IwsMemoryCache(), ttl: Duration(minutes: 5)),
));
bloc.add(PaginatedListRefresh(
resetPage: true,
cacheConfig: CacheConfig.forceRefresh(cache: IwsMemoryCache(), ttl: Duration(minutes: 15)),
));
// Delete item
bloc.add(PaginatedListItemDeleted(userId));
// Custom events
bloc.add(PaginatedListCustomEvent(event: 'archive', data: user));
Navigation Event Features
-
PaginatedListGoToPage: Navigate to a specific page number- Validates page numbers (prevents navigation to pages < 1)
- Ideal for pagination controls and direct page access
-
PaginatedListNextPage: Advance to the next page- Automatically increments current page
- Respects
hasReachedMaxto prevent unnecessary API calls - Perfect for infinite scrolling implementations
-
PaginatedListPreviousPage: Go back to the previous page- Automatically decrements current page
- Prevents navigation below page 1
- Enables backward navigation in pagination controls
-
Smart Initial Handling: All navigation events work from initial state
PaginatedListNextPageandPaginatedListPreviousPagestart from page 1 when called initiallyPaginatedListGoToPagegoes to the specified page
-
Query Parameter Support: All navigation events support optional query parameters
- Parameters are passed to the API and stored in state
- Useful for maintaining search filters across navigation
-
Cache Configuration Support: All navigation events support optional cache configuration
- Override default cache behavior per event
- Useful for different caching strategies (e.g., longer cache for background fetches)
- Automatic cache refresh on refresh events
List Widget with Infinite Scroll #
class UserListPage extends StatefulWidget {
@override
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
late UserListBloc _bloc;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_bloc = UserListBloc()..add(ListFetched());
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_isBottom) _bloc.add(ListFetched());
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: BlocProvider(
create: (context) => _bloc,
child: BlocBuilder<UserListBloc, ListState<User>>(
builder: (context, state) {
switch (state.status) {
case ListStatus.failure:
return Center(child: Text('Error: ${state.error?.error}'));
case ListStatus.success:
case ListStatus.getting:
return RefreshIndicator(
onRefresh: () async {
_bloc.add(ListRefresh());
},
child: ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax
? state.list.length
: state.list.length + 1,
itemBuilder: (context, index) {
if (index >= state.list.length) {
return const Center(child: CircularProgressIndicator());
}
final user = state.list[index];
return ListTile(
leading: CircleAvatar(child: Text(user.id.toString())),
title: Text(user.name),
subtitle: Text(user.email),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _bloc.add(ListItemDeleted(user.id)),
),
);
},
),
);
case ListStatus.initial:
return const Center(child: CircularProgressIndicator());
default:
return const SizedBox();
}
},
),
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Paginated List Widget with Enhanced Features #
class UserPaginatedListPage extends StatefulWidget {
@override
State<UserPaginatedListPage> createState() => _UserPaginatedListPageState();
}
class _UserPaginatedListPageState extends State<UserPaginatedListPage> {
late UserPaginatedListBloc _bloc;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_bloc = UserPaginatedListBloc()..add(PaginatedListGoToPage(page: 1));
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_isBottom && !_bloc.state.hasReachedMax) {
_bloc.add(PaginatedListNextPage());
}
}
bool get _isBottom {
if (!_scrollController.hasClients) return false;
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.offset;
return currentScroll >= (maxScroll * 0.9);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Users (Paginated)'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => _bloc.add(PaginatedListRefresh(resetPage: true)),
),
],
),
body: BlocProvider(
create: (context) => _bloc,
child: BlocConsumer<UserPaginatedListBloc, PaginatedListState<User>>(
listener: (context, state) {
// Handle custom event results
if (state.status == PaginatedListStatus.processingSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${state.currentCustomEvent} completed successfully')),
);
}
// Handle deletion success
if (state.status == PaginatedListStatus.deleted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User deleted successfully')),
);
// Refresh the list
_bloc.add(PaginatedListRefresh(resetPage: false));
}
},
builder: (context, state) {
switch (state.status) {
case PaginatedListStatus.failure:
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${state.error?.error}'),
ElevatedButton(
onPressed: () => _bloc.add(PaginatedListGoToPage(page: 1)),
child: const Text('Retry'),
),
],
),
);
case PaginatedListStatus.success:
case PaginatedListStatus.getting:
case PaginatedListStatus.processing:
return RefreshIndicator(
onRefresh: () async {
_bloc.add(PaginatedListRefresh(resetPage: true));
},
child: ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax
? state.list.length
: state.list.length + 1,
itemBuilder: (context, index) {
if (index >= state.list.length) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
);
}
final user = state.list[index];
final isProcessing = state.status == PaginatedListStatus.processing;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: isProcessing ? Colors.orange : Colors.blue,
child: Text(user.id.toString()),
),
title: Text(user.name),
subtitle: Text(user.email),
trailing: state.status == PaginatedListStatus.deleting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: PopupMenuButton<String>(
onSelected: (action) {
switch (action) {
case 'delete':
_showDeleteConfirmation(user);
break;
case 'archive':
_bloc.add(PaginatedListCustomEvent(
event: 'archive',
data: user,
));
break;
case 'duplicate':
_bloc.add(PaginatedListCustomEvent(
event: 'duplicate',
data: user,
));
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'archive',
child: ListTile(
leading: Icon(Icons.archive),
title: Text('Archive'),
),
),
const PopupMenuItem(
value: 'duplicate',
child: ListTile(
leading: Icon(Icons.copy),
title: Text('Duplicate'),
),
),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete, color: Colors.red),
title: Text('Delete'),
),
),
],
),
),
);
},
),
);
case PaginatedListStatus.initial:
return const Center(child: CircularProgressIndicator());
default:
return const SizedBox();
}
},
),
),
);
}
void _showDeleteConfirmation(User user) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Delete'),
content: Text('Are you sure you want to delete ${user.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_bloc.add(PaginatedListItemDeleted(user.id));
},
child: const Text('Delete', style: TextStyle(color: Colors.red)),
),
],
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Pagination Controls Example #
The new navigation events make it easy to create comprehensive pagination controls:
class PaginationControls extends StatelessWidget {
final PaginatedListBloc bloc;
final PaginatedListState state;
const PaginationControls({
Key? key,
required this.bloc,
required this.state,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final currentPage = state.pagination?.currentPage ?? 1;
final lastPage = state.pagination?.lastPage ?? 1;
final hasReachedMax = state.hasReachedMax;
return Container(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// First page button
ElevatedButton(
onPressed: currentPage > 1
? () => bloc.add(PaginatedListGoToPage(page: 1))
: null,
child: const Text('First'),
),
// Previous page button
ElevatedButton(
onPressed: currentPage > 1
? () => bloc.add(PaginatedListPreviousPage())
: null,
child: const Icon(Icons.chevron_left),
),
// Page indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Page $currentPage of $lastPage',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Next page button
ElevatedButton(
onPressed: !hasReachedMax
? () => bloc.add(PaginatedListNextPage())
: null,
child: const Icon(Icons.chevron_right),
),
// Last page button
ElevatedButton(
onPressed: !hasReachedMax
? () => bloc.add(PaginatedListGoToPage(page: lastPage))
: null,
child: const Text('Last'),
),
],
),
);
}
}
// Usage in your paginated list widget:
// Add this to your widget's build method
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Expanded(
child: BlocBuilder<UserPaginatedListBloc, PaginatedListState<User>>(
builder: (context, state) {
// Your list view here
return ListView.builder(/* ... */);
},
),
),
// Add pagination controls at the bottom
BlocBuilder<UserPaginatedListBloc, PaginatedListState<User>>(
builder: (context, state) {
return PaginationControls(
bloc: context.read<UserPaginatedListBloc>(),
state: state,
);
},
),
],
),
);
}
Choosing Between ListBloc and PaginatedListBloc
Use ListBloc when:
- You need simple pagination with basic states
- You want infinite scrolling with minimal configuration
Use PaginatedListBloc when:
- You need granular control over pagination navigation (next, previous, goto specific page)
- You want to build traditional pagination controls with page numbers
- You need minimal memory usage: PaginatedListBloc can be configured to keep only the current page's items in memory (discarding or not caching previous pages), which reduces memory footprint for very large lists.
- You require bidirectional navigation (forward and backward)
- You need validation to prevent invalid page navigation
Search Functionality #
Search BLoC Implementation #
class UserSearchBloc extends SearchBloc<User> {
UserSearchBloc() : super(
provider: IwsHttpModel(
path: 'users/search',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
);
}
Search Widget #
class UserSearchPage extends StatefulWidget {
@override
State<UserSearchPage> createState() => _UserSearchPageState();
}
class _UserSearchPageState extends State<UserSearchPage> {
late UserSearchBloc _searchBloc;
final _searchController = TextEditingController();
Timer? _debounce;
@override
void initState() {
super.initState();
_searchBloc = UserSearchBloc();
_searchController.addListener(_onSearchChanged);
}
void _onSearchChanged() {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
if (_searchController.text.isNotEmpty) {
_searchBloc.add(SearchFetched(
queryParameters: {'q': _searchController.text},
));
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _searchController,
decoration: const InputDecoration(
hintText: 'Search users...',
border: InputBorder.none,
suffixIcon: Icon(Icons.search),
),
),
),
body: BlocProvider(
create: (context) => _searchBloc,
child: BlocBuilder<UserSearchBloc, SearchState<User>>(
builder: (context, state) {
switch (state.status) {
case SearchStatus.searching:
return const Center(child: CircularProgressIndicator());
case SearchStatus.success:
return ListView.builder(
itemCount: state.list.length,
itemBuilder: (context, index) {
final user = state.list[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
case SearchStatus.failure:
return Center(child: Text('Search failed: ${state.error?.error}'));
default:
return const Center(child: Text('Start typing to search...'));
}
},
),
),
);
}
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
}
Advanced Features #
Custom Field Type #
class ColorFieldBloc extends FormFieldBloc<Color, dynamic> {
ColorFieldBloc({required super.name, super.validators, super.step})
: super(initialValue: Colors.blue);
}
// Custom widget builder
class ColorFieldBlocBuilder<BlocType extends FormBloc> extends StatelessWidget {
final FormBloc bloc;
final ColorFieldBloc blocField;
final String label;
const ColorFieldBlocBuilder({
super.key,
required this.bloc,
required this.blocField,
required this.label,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<BlocType, FormBlocState>(
bloc: bloc as BlocType,
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
const SizedBox(height: 8),
GestureDetector(
onTap: () => _showColorPicker(context),
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: blocField.value,
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
),
),
if (blocField.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
blocField.error!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
);
}
void _showColorPicker(BuildContext context) {
// Implement color picker dialog
}
}
Form with File Upload #
class ProfileFormBloc extends FormBloc {
final name = TextFieldBloc(name: 'name', validators: [Validators.required]);
final bio = TextFieldBloc(name: 'bio');
final avatar = InputFieldBloc<File?, dynamic>(name: 'avatar');
ProfileFormBloc() {
addFieldBlocs(fieldBlocs: [name, bio, avatar]);
}
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
try {
final formData = FormData.fromMap({
'name': name.value,
'bio': bio.value,
if (avatar.value != null)
'avatar': await MultipartFile.fromFile(avatar.value!.path),
});
await apiProvider.postFormData(path: 'profile', formData: formData);
emitSuccessModification(emit);
} catch (e) {
emitFailure(emit, failureResponse: e);
}
}
}
Dynamic Form Fields #
class DynamicFormBloc extends FormBloc {
final List<TextFieldBloc> dynamicFields = [];
DynamicFormBloc() {
_addInitialFields();
}
void _addInitialFields() {
final field = TextFieldBloc(name: 'field_0');
dynamicFields.add(field);
addFieldBlocs(fieldBlocs: [field]);
}
void addNewField() {
final field = TextFieldBloc(name: 'field_${dynamicFields.length}');
dynamicFields.add(field);
final allFields = [...state.fieldBlocs, field];
add(FormBlocAddFields(allFields));
}
void removeField(int index) {
if (dynamicFields.length > 1) {
dynamicFields.removeAt(index);
final remainingFields = state.fieldBlocs.where(
(field) => field.name != 'field_$index'
).toList();
add(FormBlocAddFields(remainingFields));
}
}
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
final fieldData = dynamicFields.map((field) => field.value).toList();
// Submit dynamic field data
}
}
Error Handling #
Field-Specific Errors #
// Add error to specific field
bloc.addFormError('This email is already taken', fieldBloc: bloc.emailField);
// Add general form error
bloc.addFormError('Server is temporarily unavailable');
API Error Handling #
@override
void onSubmitting(Emitter<FormBlocState> emit) async {
try {
final response = await apiProvider.post(path: 'submit', body: formData);
emitSuccess(emit, successResponse: response);
} on ApiException catch (e) {
if (e.statusCode == 422) {
// Handle validation errors from API
emitFailure(emit, failureResponse: FormErrorModel(
message: e.error,
errorInputs: e.validationErrors, // Maps field names to error messages
));
} else {
emitFailure(emit, failureResponse: FormErrorModel(message: e.error));
}
} catch (e) {
emitFailure(emit, failureResponse: FormErrorModel(
message: 'An unexpected error occurred'
));
}
}
Modified Fields Tracking #
The FormBloc automatically tracks which fields have been modified since the form was initialized or last successfully submitted. This is useful for:
- Showing "unsaved changes" warnings
- Implementing "dirty" form detection
- Conditional validation or UI updates
- Displaying visual indicators for modified fields
Using Modified Fields #
class MyFormPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<MyFormBloc, FormBlocState>(
builder: (context, state) {
final bloc = context.read<MyFormBloc>();
return WillPopScope(
onWillPop: () async {
// Warn user about unsaved changes
if (bloc.hasModifiedFields()) {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Unsaved Changes'),
content: Text(
'You have unsaved changes in: ${bloc.getModifiedFields().join(", ")}\n'
'Are you sure you want to leave?'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('Stay'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('Leave'),
),
],
),
) ?? false;
}
return true;
},
child: Scaffold(
appBar: AppBar(
title: Text('My Form'),
actions: [
// Show indicator when there are unsaved changes
if (bloc.hasModifiedFields())
Padding(
padding: EdgeInsets.all(16),
child: Icon(Icons.circle, size: 8, color: Colors.orange),
),
],
),
body: Column(
children: [
// Show which fields have been modified
if (bloc.hasModifiedFields())
Container(
padding: EdgeInsets.all(8),
color: Colors.orange.shade100,
child: Text(
'Modified fields: ${bloc.getModifiedFields().join(", ")}',
style: TextStyle(fontSize: 12),
),
),
// Your form fields...
TextFieldBlocBuilder<MyFormBloc>(
bloc: bloc,
blocField: bloc.nameField,
decoration: InputDecoration(
labelText: 'Name',
// Visual indicator for modified field
suffixIcon: bloc.isFieldModified('name')
? Icon(Icons.edit, color: Colors.orange, size: 16)
: null,
),
),
// Submit button with dynamic text
ElevatedButton(
onPressed: () => bloc.submit(),
child: Text(
bloc.hasModifiedFields() ? 'Save Changes' : 'Submit'
),
),
// Manual clear button (optional)
if (bloc.hasModifiedFields())
TextButton(
onPressed: () => bloc.clearModifiedFields(),
child: Text('Discard Changes'),
),
],
),
),
);
},
);
}
}
Modified Fields API #
// Check if any field has been modified
bool hasChanges = bloc.hasModifiedFields();
// Get list of modified field names
List<String> modifiedFields = bloc.getModifiedFields();
// Example: ['name', 'email', 'age']
// Check if a specific field has been modified
bool nameWasModified = bloc.isFieldModified('name');
// Manually clear the modified fields tracking
bloc.clearModifiedFields();
Automatic Clearing #
Modified fields are automatically cleared when:
- Form is successfully submitted (
emitSuccess,emitSuccessCreation,emitSuccessModification) - Form deletion is successful (
emitDeleteSuccessful)
Modified fields are NOT cleared when:
- Form validation fails
- Form submission fails (API error)
- Form is loading or in any non-success state
This behavior ensures that users don't lose track of their changes if something goes wrong during submission.
Migration Guide: v3.x → v4.0.0 #
Required: Error message language changed #
All validation messages now default to English. If your app relies on Spanish error messages, add this to main():
void main() {
FormBlocConfig.setLocalizations(const FormBlocLocalizationsEs());
runApp(const MyApp());
}
Recommended: Rename loadFaild → loadFailed #
// ❌ Old (loadFaild — removed in v5.0.0)
// FormBlocConsumer<MyBloc>(
// loadFaild: const Center(child: Text('Error loading')),
// )
// ✅ v4.0.0+ / v5.0.0
FormBlocConsumer<MyBloc>(
loadFailed: const Center(child: Text('Error loading')),
// ...
)
Check: SearchState new fields #
If you have code that compares SearchState instances or uses copyWith, note that SearchState now includes two new fields with defaults — no action required unless you subclass SearchState:
// These are now available:
state.queryParameters // Map<String, String>? — active query params
state.currentPage // int — current page (default: 1)
// Update search results with new filters:
bloc.add(SearchRefresh(queryParameters: {'status': 'active'}));
No action needed: Performance improvements #
buildWhen is now built into all field builders and O(1) field lookup is automatic — no code changes required to benefit.
New opt-in features #
All new v4.0.0 features (resetForm, crossValidators, validationDebounce, RadioGroupFieldBlocBuilder, SliderFieldBlocBuilder, SelectFieldBloc.itemsLoader) are purely additive and opt-in. Existing code continues to work unchanged.
Best Practices #
- Form Organization: Group related fields logically and use meaningful field names
- Validation Strategy: Combine client-side validation with server-side validation
- State Management: Use FormBlocConsumer for handling form states and user feedback
- Performance: Use BlocBuilder selectively to rebuild only necessary widgets
- Accessibility: Provide proper labels, hints, and error messages for screen readers
- Testing: Write unit tests for your form logic and validation rules
Testing #
import 'package:flutter_test/flutter_test.dart';
import 'package:iws_form_bloc/iws_form_bloc.dart';
void main() {
group('LoginBloc', () {
late LoginBloc loginBloc;
setUp(() {
loginBloc = LoginBloc();
});
test('initial state is correct', () {
expect(loginBloc.state.status, FormStatus.loaded);
expect(loginBloc.email.value, '');
expect(loginBloc.password.value, '');
});
test('email validation works correctly', () {
loginBloc.updateField(loginBloc.email, 'invalid-email');
expect(loginBloc.email.validate(), isNotNull);
loginBloc.updateField(loginBloc.email, 'test@example.com');
expect(loginBloc.email.validate(), isNull);
});
test('form submission with valid data succeeds', () async {
loginBloc.updateField(loginBloc.email, 'test@example.com');
loginBloc.updateField(loginBloc.password, 'ValidPass123');
loginBloc.submit();
// Add your assertions based on expected behavior
});
});
group('PaginatedListBloc', () {
late PaginatedListBloc<TestModel> bloc;
late MockProvider provider;
setUp(() {
provider = MockProvider();
bloc = PaginatedListBloc<TestModel>(provider: provider);
});
test('initial state is correct', () {
expect(bloc.state.status, PaginatedListStatus.initial);
expect(bloc.state.list, isEmpty);
expect(bloc.state.hasReachedMax, false);
});
test('fetch success updates state correctly', () async {
// Setup mock response
when(() => provider.fetchPaginated(any()))
.thenAnswer((_) async => PaginatedData(
data: [TestModel(id: 1, name: 'Test')],
meta: TestMeta(lastPage: 2),
));
bloc.add(PaginatedListGoToPage(page: 1));
await Future.delayed(Duration.zero);
expect(bloc.state.status, PaginatedListStatus.success);
expect(bloc.state.list.length, 1);
expect(bloc.state.hasReachedMax, false);
});
test('custom event success', () async {
bool eventCalled = false;
final customBloc = PaginatedListBloc<TestModel>(
provider: provider,
customEvents: {
'test': (data) async { eventCalled = true; },
},
);
customBloc.add(PaginatedListCustomEvent(
event: 'test',
data: TestModel(id: 1, name: 'Test'),
));
await Future.delayed(Duration.zero);
expect(eventCalled, true);
expect(customBloc.state.status, PaginatedListStatus.processingSuccess);
});
});
}