iws_form_bloc 2.7.0
iws_form_bloc: ^2.7.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: Various field types including text, password, boolean, select, multi-select, date, and more
- Built-in validation: Pre-built validators for common use cases (email, password, required, etc.)
- Reactive forms: Real-time validation and state updates using BLoC pattern
- 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
- Custom field builders: Ready-to-use UI components for 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
Installation #
Add this to your package's pubspec.yaml file:
dependencies:
iws_form_bloc: ^latest_version
🚀 What's New in Pagination #
The PaginatedListBloc has been enhanced with more intuitive navigation events:
Migration from Previous Versions #
// ❌ Old way (deprecated)
bloc.add(PaginatedListFetched()); // Initial load
bloc.add(PaginatedListFetched(page: 2)); // Go to page 2
bloc.add(PaginatedListFetched(page: null)); // Next page
// ✅ New way (recommended)
bloc.add(PaginatedListGoToPage(page: 1)); // Initial load or go to page 1
bloc.add(PaginatedListGoToPage(page: 2)); // Go to page 2
bloc.add(PaginatedListNextPage()); // Next page
bloc.add(PaginatedListPreviousPage()); // Previous page (new!)
Benefits of the new approach:
- Clearer intent: Each event clearly expresses the navigation action
- Better UX: Backward navigation is now possible
- Built-in validation: Prevents invalid page navigation automatically
- Type safety: Required parameters where needed, optional where not
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>(
loadFaild: 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 #
Text Field #
// 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,
)
Password Field #
// 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',
),
)
Boolean/Checkbox Field #
// 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',
)
Select/Dropdown Field #
// 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...',
)
Multi-Select Field #
// 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),
)
Date Field #
// 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,
)
Calendar 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),
)
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],
);
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'
));
}
}
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);
});
});
}