iws_form_bloc 4.1.0
iws_form_bloc: ^4.1.0 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
Deprecated #
FormBlocConsumer(loadFaild: ...)→ useloadFailed:instead
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.
See FIELD_WIDGETS.md for detailed documentation on:
- TextFieldBloc: Text input, password input
- BooleanFieldBloc: Checkboxes, segmented buttons
- SelectFieldBloc: Dropdown menus, radio groups, async loading
- MultiSelectFieldBloc: Multi-selection with chips
- InputFieldBloc: Date pickers, calendars, sliders, list tile selection
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
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 #
// ❌ v3.x — still works but @Deprecated
FormBlocConsumer<MyBloc>(
loadFaild: const Center(child: Text('Error loading')),
// ...
)
// ✅ v4.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);
});
});
}