iws_form_bloc 2.4.0
iws_form_bloc: ^2.4.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
- Paginated lists: Built-in support for paginated data with infinite scrolling
- 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
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,
};
}
class UserListBloc extends ListBloc<User> {
UserListBloc() : super(
provider: IwsHttpModel(
path: 'users',
iwsHttp: IwsHttp(),
toJson: User.toJson,
fromJson: User.fromJson,
),
);
}
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();
}
}
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
});
});
}
License #
This project is licensed under the MIT License - see the LICENSE file for details.