iws_form_bloc 5.0.1 copy "iws_form_bloc: ^5.0.1" to clipboard
iws_form_bloc: ^5.0.1 copied to clipboard

Simplified creation of forms and listings using the BLoC pattern.

IWS Form BLoC Package #

A comprehensive Flutter package that simplifies the creation of forms and data listings using the BLoC pattern. It provides a powerful, type-safe, and reactive approach to building forms with built-in validation, state management, and HTTP integration.

Features #

  • Type-safe form fields: Text, password, boolean, select, multi-select, radio group, slider, date, and more
  • Built-in validation: Pre-built validators for common use cases (email, password, required, etc.)
  • Cross-field validation: Validate fields relative to each other (password match, date ranges, etc.)
  • Validation debounce: Throttle validation during rapid typing with configurable delay
  • Reactive forms: Real-time validation and state updates using BLoC pattern
  • Internationalization (i18n): Built-in English and Spanish support; fully customizable via FormBlocLocalizations
  • Form reset: Reset all fields to initial state with resetForm()
  • HTTP integration: Seamless integration with REST APIs through IWS HTTP
  • HTTP caching: Built-in caching support for improved performance and offline capabilities
  • Paginated lists: Built-in support for paginated data with infinite scrolling and configurable pagination behavior
  • Advanced pagination: PaginatedListBloc with 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 buildWhen 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
  • 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: Renders RadioListTile widgets from a SelectFieldBloc
  • SliderFieldBlocBuilder: Renders a Slider from an InputFieldBloc<double, D>

Async Item Loading for SelectFieldBloc #

final roleField = SelectFieldBloc<Role, dynamic>(
  name: 'role',
  itemsLoader: () async => await api.getRoles(),
);

// Trigger loading:
await bloc.roleField.loadItems();

Performance Improvements #

  • All 13 field builders use buildWhen — only the changed field triggers a rebuild
  • FormBlocState.fieldByName(name) uses O(1) HashMap lookup instead of linear search

Removed in v5.0.0 #

  • FormBlocConsumer(loadFaild: ...) → removed; use loadFailed: instead
  • ConfirmPasswordFieldBloc → removed; use TextFieldBloc with crossValidators or custom validator

Quick Start #

1. Import the package #

import 'package:iws_form_bloc/iws_form_bloc.dart';

Form Implementation #

Creating a Form BLoC #

class LoginBloc extends FormBloc {
  final authenticationProvider = IwsHttp.setup(authority: 'your-api.com');

  final email = TextFieldBloc(name: 'email', validators: [Validators.email, Validators.required]);
  final password = TextFieldBloc(name: 'password', validators: [Validators.password, Validators.required]);
  final rememberMe = BooleanFieldBloc(name: 'remember_me');

  LoginBloc() {
    addFieldBlocs(fieldBlocs: [email, password, rememberMe]);
  }

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async {
    try {
      final response = await authenticationProvider.post(
        path: 'auth/login',
        body: {
          'email': email.value,
          'password': password.value,
          'remember_me': rememberMe.value ?? false,
        }
      );
      
      emitSuccessCreation(emit, successResponse: response);
    } on ApiException catch (e) {
      emitFailure(emit, failureResponse: FormErrorModel(message: e.error, exception: e));
    }
  }
}

Using Form Widgets #

class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: BlocProvider(
        create: (context) => LoginBloc(),
        child: Builder(
          builder: (context) {
            final bloc = BlocProvider.of<LoginBloc>(context);
            
            return FormBlocConsumer<LoginBloc>(
              loadFailed: const Center(child: Text('Error loading form')),
              onFailure: (context, state) {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text(state.failureResponse?.message ?? 'Login failed'))
                );
              },
              onSuccess: (context, state) {
                Navigator.pushReplacementNamed(context, '/home');
              },
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  children: [
                    // Email field
                    TextFieldBlocBuilder<LoginBloc>(
                      bloc: bloc,
                      blocField: bloc.email,
                      inputType: TextInputType.emailAddress,
                      textInputAction: TextInputAction.next,
                      decoration: const InputDecoration(
                        labelText: 'Email',
                        prefixIcon: Icon(Icons.email),
                        border: OutlineInputBorder(),
                      ),
                    ),
                    const SizedBox(height: 16),
                    
                    // Password field
                    PasswordFieldBlocBuilder<LoginBloc>(
                      bloc: bloc,
                      blocField: bloc.password,
                      textInputAction: TextInputAction.done,
                      decoration: const InputDecoration(
                        labelText: 'Password',
                        prefixIcon: Icon(Icons.lock),
                        border: OutlineInputBorder(),
                      ),
                      onSubmitted: (value) => bloc.submit(),
                    ),
                    const SizedBox(height: 16),
                    
                    // Remember me checkbox
                    CheckboxFieldBlocBuilder<LoginBloc>(
                      bloc: bloc,
                      blocField: bloc.rememberMe,
                      label: 'Remember me',
                    ),
                    const SizedBox(height: 24),
                    
                    // Submit button
                    SizedBox(
                      width: double.infinity,
                      child: ElevatedButton(
                        onPressed: () => bloc.submit(),
                        child: const Text('LOGIN'),
                      ),
                    ),
                  ],
                ),
              ),
            );
          },
        ),
      ),
    );
  }
}

Field Types and Builders #

This package provides a comprehensive set of field types and corresponding widget builders for all common use cases. Each field type is designed to handle a specific data type with automatic validation, state management, and UI rendering.

Each field builder is optimized with buildWhen to ensure only the affected field rebuilds when its value changes, providing excellent performance even with complex forms.

Key Features:

  • Type-safe generic builders with full IDE support
  • Automatic validation and error display
  • Reactive state management via BLoC
  • Built-in support for async data loading
  • Customizable UI through parameters
  • O(1) field lookup by name via HashMap

This document organizes all available field types and their associated widgets. Each section represents a specific field bloc type and lists all compatible widgets.

TextFieldBloc #

The TextFieldBloc is the foundation for text input fields. It provides text validation, formatting, and state management.

TextFieldBlocBuilder

Basic text input widget with customizable decorations and text formatting options.

// In BLoC
final nameField = TextFieldBloc(
  name: 'name',
  validators: [Validators.required],
);

// In Widget
TextFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.nameField,
  decoration: const InputDecoration(
    labelText: 'Full Name',
    hintText: 'Enter your full name',
  ),
  maxLength: 50,
  textCapitalization: TextCapitalization.words,
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The TextFieldBloc instance
  • decoration: InputDecoration for styling
  • maxLength: Maximum character limit
  • textCapitalization: Text capitalization behavior
  • keyboardType: Keyboard type (default: text)
  • obscureText: Hide text input (for passwords, use PasswordFieldBlocBuilder instead)

PasswordFieldBlocBuilder

Specialized widget for password input with built-in visibility toggle.

// In BLoC
final passwordField = TextFieldBloc(
  name: 'password',
  validators: [Validators.password, Validators.required],
);

// In Widget
PasswordFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.passwordField,
  decoration: const InputDecoration(
    labelText: 'Password',
    helperText: 'At least 8 characters with uppercase, lowercase and number',
  ),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The TextFieldBloc instance
  • decoration: InputDecoration for styling
  • showPasswordIcon: Show/hide password toggle button (default: true)

InputChipTextBlocBuilder

Chip-based widget for displaying and managing a single text value using Material InputChip.

// In BLoC
final tagField = TextFieldBloc(
  name: 'tag',
  validators: [Validators.required],
);

// In Widget
InputChipTextBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.tagField,
  label: 'Enter Tag',
  addLabel: 'Add tag',
  prefixIcon: Icon(Icons.label_outline),
  itemBuilder: (tag) => Text(tag ?? 'No tag'),
  onAdd: () {
    // Show tag input dialog
  },
  onRemoved: (tag) {
    // Handle tag removal
  },
  onPressed: (tag) {
    // Handle tag press
  },
  tooltip: (tag) => 'Tag: $tag',
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The TextFieldBloc instance
  • label: Widget label
  • addLabel: Label displayed in the chip when field is empty
  • itemBuilder: Function to build the chip label for the text value
  • prefixIcon: Optional icon to display before the chip content
  • onAdd: Callback when the add chip is pressed (when value is empty)
  • onRemoved: Callback when the remove button is pressed
  • onPressed: Callback when the chip is pressed
  • showRemoveButton: Show delete/remove button on the chip (default: true)
  • deleteTooltipMessage: Tooltip message for the delete button
  • tooltip: Function to generate tooltip text for the value
  • enabled: Enable/disable the chip interaction (default: true)

Key Differences from InputChipBlocBuilder:

  • Uses TextFieldBloc instead of InputFieldBloc
  • Works with simple String values instead of complex objects
  • Simpler integration when you need text-based chip selection
  • Static prefixIcon instead of dynamic builder function

BooleanFieldBloc #

The BooleanFieldBloc handles boolean (true/false) values with built-in validation.

CheckboxFieldBlocBuilder

Traditional checkbox widget with label and subtitle support.

// In BLoC
final agreeToTerms = BooleanFieldBloc(
  name: 'agree_terms',
  validators: [(value) => value == true ? null : 'You must agree to terms'],
);

// In Widget
CheckboxFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.agreeToTerms,
  label: 'I agree to the Terms and Conditions',
  subtitle: 'Please read our terms carefully',
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The BooleanFieldBloc instance
  • label: Main label text
  • subtitle: Optional secondary text
  • enabled: Enable/disable the checkbox (default: true)

ChecksegmentFieldBlocBuilder

Modern segmented button widget offering visual true/false selection with custom labels.

// In BLoC
final notificationsField = BooleanFieldBloc(
  name: 'notifications',
  initialValue: true,
);

// In Widget
ChecksegmentFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.notificationsField,
  trueLabel: 'Enabled',
  falseLabel: 'Disabled',
  onChanged: (value) => print('Notifications: $value'),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The BooleanFieldBloc instance
  • trueLabel: Label displayed for the true/enabled segment
  • falseLabel: Label displayed for the false/disabled segment
  • enabled: Enable or disable the segmented button (default: true)
  • onChanged: Optional callback triggered when the value changes

SelectFieldBloc<T, D> #

The SelectFieldBloc manages single-value selection from a list of items. Ideal for dropdowns, radio groups, and selection dialogs.

Classic dropdown/select widget for choosing one item from a list.

// In BLoC
final countryField = SelectFieldBloc<Country, dynamic>(
  name: 'country',
  validators: [Validators.required],
  items: countryList,
);

// In Widget
DropdownFieldBlocBuilder<MyFormBloc, Country>(
  bloc: bloc,
  blocField: bloc.countryField,
  label: 'Select Country',
  itemBuilder: (country) => Text(country.name),
  showEmptyItem: true,
  emptyLabel: 'Choose a country...',
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The SelectFieldBloc instance
  • label: Widget label
  • itemBuilder: Function to build each item widget
  • showEmptyItem: Show empty/null option (default: false)
  • emptyLabel: Label for the empty option

Load items dynamically from an API or async source.

// In BLoC — items loaded lazily from API
final countryField = SelectFieldBloc<Country, dynamic>(
  name: 'country',
  validators: [Validators.required],
  itemsLoader: () async => await apiProvider.getCountries(),
);

CountryFormBloc() {
  addFieldBlocs(fieldBlocs: [countryField]);
  // Load items when the form initializes
  countryField.loadItems().then((_) => emit(state.copyWith()));
}

// In Widget — show loading indicator while items load
DropdownFieldBlocBuilder<MyFormBloc, Country>(
  bloc: bloc,
  blocField: bloc.countryField,
  label: 'Country',
  showLoading: true,
  itemBuilder: (country) => Text(country.name),
)

Parameters:

  • showLoading: Display loading indicator while items are being fetched (default: true)
  • loadingWidget: Custom widget to show during loading

RadioGroupFieldBlocBuilder

Radio button group widget for visually distinct selection options.

// In BLoC
final shippingMethod = SelectFieldBloc<String, dynamic>(
  name: 'shipping_method',
  validators: [Validators.required],
  items: ['Standard', 'Express', 'Overnight'],
);

// In Widget
RadioGroupFieldBlocBuilder<MyFormBloc, String>(
  bloc: bloc,
  blocField: bloc.shippingMethod,
  label: 'Shipping Method',
  itemBuilder: (context, value) => Text(value),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The SelectFieldBloc instance
  • label: Widget label
  • itemBuilder: Function to build each radio option label
  • enabled: Enable/disable radio group (default: true)

ColorSelectBlocBuilder

Specialized dropdown widget for selecting colors with visual color preview circles.

// In BLoC
final themeColorField = SelectFieldBloc<ColorType, dynamic>(
  name: 'theme_color',
  validators: [Validators.required],
  items: [
    ColorType(name: 'Red', color: Colors.red),
    ColorType(name: 'Blue', color: Colors.blue),
    ColorType(name: 'Green', color: Colors.green),
  ],
);

// In Widget
ColorSelectBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.themeColorField,
  label: 'Select Theme Color',
  prefixIcon: Icon(Icons.palette),
  showEmptyItem: true,
  onChanged: (color) => print('Selected color: ${color?.name}'),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The SelectFieldBloc<ColorType, dynamic> instance
  • label: Widget label
  • prefixIcon: Optional icon to display before the dropdown
  • showEmptyItem: Show empty/null option (default: true)
  • border: Custom InputBorder for the dropdown
  • readOnly: Disable color selection (default: false)
  • onChanged: Optional callback triggered when color is selected

Features:

  • Color preview displayed as circular avatars next to color names
  • Built on top of DropdownFieldBlocBuilder for consistency
  • Automatic color visualization without custom itemBuilder
  • Full support for validation and error display
  • Responsive to device theme and styling

ColorType Model: The widget expects items of type ColorType which includes:

class ColorType {
  final String name;
  final Color color;
  
  ColorType({required this.name, required this.color});
}

MultiSelectFieldBloc<T, D> #

The MultiSelectFieldBloc handles selection of multiple items from a list.

FilterChipFieldBlocBuilder

Chip-based widget for selecting multiple items with visual feedback.

// In BLoC
final skillsField = MultiSelectFieldBloc<String, dynamic>(
  name: 'skills',
  items: ['Flutter', 'Dart', 'React', 'Node.js', 'Python'],
);

// In Widget
FilterChipFieldBlocBuilder<MyFormBloc, String>(
  bloc: bloc,
  blocField: bloc.skillsField,
  label: 'Select your skills',
  itemBuilder: (skill) => Text(skill),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The MultiSelectFieldBloc instance
  • label: Widget label
  • itemBuilder: Function to build each chip label
  • onSelected: Optional callback when item is selected/deselected
  • spacing: Space between chips (default: 8.0)
  • runSpacing: Vertical space between rows (default: 8.0)

CheckboxListFieldBlocBuilder

Checkbox list widget for selecting multiple items with optional subtitles per item.

// In BLoC
final permissionsField = MultiSelectFieldBloc<Permission, dynamic>(
  name: 'permissions',
  items: [
    Permission(id: 1, name: 'Read', description: 'View content'),
    Permission(id: 2, name: 'Write', description: 'Create and edit'),
    Permission(id: 3, name: 'Delete', description: 'Remove content'),
  ],
);

// In Widget
CheckboxListFieldBlocBuilder<MyFormBloc, Permission>(
  bloc: bloc,
  blocField: bloc.permissionsField,
  label: 'Select Permissions',
  itemTitleBuilder: (permission) => Text(permission.name),
  itemSubtitleBuilder: (permission) => Text(permission.description),
  onChanged: (selected) => print('Selected: ${selected.length} permissions'),
  readOnly: false,
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The MultiSelectFieldBloc<T, dynamic> instance
  • label: Section label displayed above the list
  • itemTitleBuilder: Function to build the title widget for each item
  • itemSubtitleBuilder: Optional function to build subtitle for each item
  • onChanged: Optional callback triggered when selection changes
  • enabled: Enable/disable checkbox interaction (default: true)
  • readOnly: Disable modification but allow viewing (default: false)

Features:

  • Displays items as a vertical list of CheckboxListTile
  • Support for optional subtitles under each item
  • Error state visualization with visual feedback
  • Full validation support
  • Automatic state management with BLoC
  • Responsive to form status changes

Key Differences from FilterChipFieldBlocBuilder:

  • Vertical list layout instead of horizontal chips/wrapping
  • Support for subtitles on each item
  • CheckboxListTile styling for consistent Material Design
  • Better for longer lists with descriptive text
  • More touch-friendly for mobile interfaces

InputFieldBloc<T, D> #

The InputFieldBloc is a generic field for complex value types. It's used for dates, numbers, objects, and other non-simple types.

DateFieldBlocBuilder

Date picker widget with customizable date format and range.

// In BLoC
final birthDateField = InputFieldBloc<DateTime?, dynamic>(
  name: 'birth_date',
  validators: [Validators.required],
);

// In Widget
DateFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.birthDateField,
  label: 'Birth Date',
  firstDate: DateTime(1950),
  lastDate: DateTime.now(),
  dateFormat: (date) => DateFormat('dd/MM/yyyy').format(date),
  showClearIcon: true,
  readOnly: false,
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<DateTime?, D> instance
  • label: Widget label
  • firstDate: Earliest selectable date
  • lastDate: Latest selectable date
  • dateFormat: Function to format date for display
  • showClearIcon: Display clear button (default: true)
  • readOnly: Disable date editing (default: false)

CalendarFieldBlocBuilder

Full calendar widget for date selection with visual calendar view.

// In BLoC
final eventDateField = InputFieldBloc<DateTime?, dynamic>(
  name: 'event_date',
);

// In Widget
CalendarFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.eventDateField,
  firstDate: DateTime.now(),
  lastDate: DateTime.now().add(const Duration(days: 365)),
  dateFormat: (date) => DateFormat('EEEE, MMMM d, y').format(date),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<DateTime?, D> instance
  • firstDate: Earliest selectable date
  • lastDate: Latest selectable date
  • dateFormat: Function to format date for display
  • showTodayButton: Show "Today" button (default: true)

TimeFieldBlocBuilder

Time picker widget with customizable formatting and optional clear button.

// In BLoC
final meetingTimeField = InputFieldBloc<TimeOfDay?, dynamic>(
  name: 'meeting_time',
  validators: [Validators.required],
);

// In Widget
TimeFieldBlocBuilder<MyFormBloc>(
  bloc: bloc,
  blocField: bloc.meetingTimeField,
  decoration: const InputDecoration(
    labelText: 'Meeting Time',
    hintText: 'Select a time',
    prefixIcon: Icon(Icons.access_time),
  ),
  showClearIcon: true,
  onChanged: (time) => print('Selected time: $time'),
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<TimeOfDay?, D> instance
  • decoration: InputDecoration for styling (optional)
  • showClearIcon: Display clear/reset button (default: false)
  • onChanged: Optional callback triggered when time is selected

Features:

  • Read-only text field displaying formatted time
  • Material showTimePicker dialog integration
  • Automatic time formatting using device locale
  • Optional clear button to reset the time
  • Error display support with validation messages

SliderFieldBlocBuilder

Slider widget for numeric value selection with optional labels and divisions.

// In BLoC
final volumeField = InputFieldBloc<double, void>(
  name: 'volume',
  initialValue: 50.0,
);

// In Widget
SliderFieldBlocBuilder<MyFormBloc, void>(
  bloc: bloc,
  blocField: bloc.volumeField,
  label: 'Volume',
  min: 0,
  max: 100,
  divisions: 100,
  thumbLabelBuilder: (value) => '${value.toInt()}%',
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<double, D> instance
  • label: Widget label
  • min: Minimum slider value (default: 0.0)
  • max: Maximum slider value (default: 100.0)
  • divisions: Number of discrete divisions
  • thumbLabelBuilder: Function to format the thumb label
  • activeColor: Color of the active slider portion
  • inactiveColor: Color of the inactive slider portion

ListTileSelectBlocBuilder

List tile-based widget for selecting a single complex object.

// In BLoC
final selectedItemField = InputFieldBloc<Item?, dynamic>(
  name: 'selected_item',
  validators: [Validators.required],
);

// In Widget
ListTileSelectBlocBuilder<MyFormBloc, Item>(
  bloc: bloc,
  blocField: bloc.selectedItemField,
  label: 'Select Item',
  addLabel: 'Add an item',
  prefixIconBuilder: (item) => Icon(Icons.category),
  itemBuilder: (item) => Text(item.name),
  onAdd: () {
    // Logic to select item
  },
  onRemoved: (item) {
    // Handle item removal if needed
  },
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<T?, D> instance
  • label: Widget label
  • addLabel: Label for the add/select action
  • itemBuilder: Function to build the item display
  • prefixIconBuilder: Optional function to build an icon before the item
  • onAdd: Callback when add button is pressed
  • onRemoved: Callback when item is removed

InputChipBlocBuilder

Chip-based widget for selecting or displaying a single item using Material InputChip.

// In BLoC
final selectedItemField = InputFieldBloc<Item?, dynamic>(
  name: 'selected_item',
  validators: [Validators.required],
);

// In Widget
InputChipBlocBuilder<MyFormBloc, Item>(
  bloc: bloc,
  blocField: bloc.selectedItemField,
  label: 'Select Item',
  addLabel: 'Add item',
  prefixIconBuilder: (item) => item != null ? Icon(Icons.check) : Icon(Icons.add),
  itemBuilder: (item) => Text(item.name),
  onAdd: () {
    // Show item picker dialog
  },
  onRemoved: (item) {
    // Handle item removal
  },
  onPressed: (item) {
    // Handle item press
  },
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<T?, D> instance
  • label: Widget label
  • addLabel: Label displayed in the chip when no item is selected
  • itemBuilder: Function to build the chip label for the selected item
  • prefixIconBuilder: Function to build an icon before the chip content
  • onAdd: Callback when the add chip is pressed (when value is null)
  • onRemoved: Callback when the remove button is pressed
  • onPressed: Callback when the chip is pressed
  • showRemoveButton: Show delete/remove button on the chip (default: true)
  • deleteTooltipMessage: Tooltip message for the delete button
  • tooltip: Function to generate tooltip text for the selected item
  • enabled: Enable/disable the chip interaction (default: true)

InputChipMultiBlocBuilder

Multi-chip widget for selecting or displaying multiple items using Material InputChip.

// In BLoC
final selectedTagsField = InputFieldBloc<List<Tag>, dynamic>(
  name: 'selected_tags',
  validators: [Validators.required],
  initialValue: [],
);

// In Widget
InputChipMultiBlocBuilder<MyFormBloc, Tag>(
  bloc: bloc,
  blocField: bloc.selectedTagsField,
  label: 'Select Tags',
  prefixIconBuilder: (tag) => tag != null ? Icon(Icons.label) : Icon(Icons.add),
  itemBuilder: (tag) => Text(tag.name),
  isEqual: (tag1, tag2) => tag1.id == tag2.id,
  onAdd: () {
    // Show tag picker dialog
  },
  onRemoved: (tag) {
    // Handle tag removal
  },
  onPressed: (tag) {
    // Handle tag press
  },
  tooltip: (tag) => 'Tag: ${tag.name}',
)

Parameters:

  • bloc: Reference to your FormBloc
  • blocField: The InputFieldBloc<List
  • label: Widget label
  • itemBuilder: Function to build the chip label for each selected item
  • prefixIconBuilder: Function to build an icon before the chip content
  • isEqual: Required function to compare items for equality (used when removing items)
  • onAdd: Callback when the add button is pressed
  • onRemoved: Callback when a chip's remove button is pressed
  • onPressed: Callback when a chip is pressed
  • deleteTooltipMessage: Tooltip message for the delete button
  • tooltip: Function to generate tooltip text for each item
  • enabled: Enable/disable chip interaction (default: true)

Key Differences from InputChipBlocBuilder:

  • Handles List<T> instead of single T? values
  • Displays multiple chips in a Wrap layout for responsive multi-line display
  • Requires isEqual function for comparing items (important for list operations)
  • Add button appears as trailing icon in the ListTile header
  • Each chip can be individually removed

Field Builder Best Practices #

  1. Type Safety: Always specify the generic types explicitly:

    DropdownFieldBlocBuilder<MyFormBloc, Country>(...)
    
  2. Register Fields: Ensure all field blocs are registered with addFieldBlocs():

    addFieldBlocs(fieldBlocs: [field1, field2, field3]);
    
  3. Validation: Combine field-level validation with cross-field validation:

    final email = TextFieldBloc(
      name: 'email',
      validators: [Validators.required, Validators.email],
    );
    
  4. Async Items: For SelectFieldBloc with async items, load them during bloc initialization or on demand.

  5. Performance: Field builders use buildWhen to optimize rebuilds. Only the changed field triggers a rebuild.

Validation #

Built-in Validators #

class Validators {
  static String? required(dynamic value);      // Checks if field is not empty
  static String? email(String? value);        // Validates email format
  static String? password(String? value);     // Strong password validation
  static String? integer(String? value);      // Integer number validation
  static String? float(String? value);        // Float number validation
  static String? alphaDash(String? value);    // Letters, numbers, dashes only
}

Custom Validators #

// Custom validator function
String? phoneValidator(String? value) {
  if (value == null || value.isEmpty) return null;
  
  if (!RegExp(r'^\+?[\d\s-()]+$').hasMatch(value)) {
    return 'Please enter a valid phone number';
  }
  return null;
}

// Using custom validator
final phoneField = TextFieldBloc(
  name: 'phone',
  validators: [Validators.required, phoneValidator],
);

Internationalization (i18n) #

Validation error messages and UI strings default to English since v4.0.0. Two approaches are available to switch languages.

Option A — Fixed language at startup #

Call FormBlocConfig.setLocalizations() once in main(). Use this when the app has a fixed language regardless of the device locale.

import 'package:iws_form_bloc/iws_form_bloc.dart';

void main() {
  // English (default — no configuration needed)
  // FormBlocConfig.setLocalizations(const FormBlocLocalizationsEn());

  // Spanish
  FormBlocConfig.setLocalizations(const FormBlocLocalizationsEs());

  runApp(const MyApp());
}

Option B — Reactive to device/app locale #

Add FormBlocLocalizationsDelegate to MaterialApp.localizationsDelegates. It automatically calls FormBlocConfig.setLocalizations() whenever the locale changes — no manual setup needed. Use this when the app follows the device locale or allows the user to switch language at runtime.

import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:iws_form_bloc/iws_form_bloc.dart';

MaterialApp(
  localizationsDelegates: [
    FormBlocLocalizationsDelegate.delegate, // built-in en + es
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
  supportedLocales: const [Locale('en'), Locale('es')],
)

Pass a custom map to support additional languages:

FormBlocLocalizationsDelegate(
  localizations: {
    Locale('en'): FormBlocLocalizationsEn(),
    Locale('es'): FormBlocLocalizationsEs(),
    Locale('pt'): FormBlocLocalizationsPt(), // custom implementation
  },
)

Custom Language #

Implement FormBlocLocalizations to provide any language:

class FormBlocLocalizationsPt extends FormBlocLocalizations {
  const FormBlocLocalizationsPt();

  @override String get requiredField => 'Este campo é obrigatório';
  @override String get invalidEmail => 'Digite um e-mail válido';
  @override String get invalidPassword => 'A senha deve ter letras maiúsculas, minúsculas e números';
  @override String get passwordsDoNotMatch => 'As senhas não coincidem';
  @override String get invalidInteger => 'Digite um número inteiro válido';
  @override String get invalidFloat => 'Digite um número válido';
  @override String get alphaDashOnly => 'Use apenas letras, números, traços e sublinhados';
  @override String get pleaseReviewData => 'Revise os dados informados';
  @override String get invalidCommand => 'Comando inválido';
  @override String get unexpectedError => 'Erro inesperado';
}

// Option A — fixed at startup:
FormBlocConfig.setLocalizations(const FormBlocLocalizationsPt());

// Option B — via delegate:
FormBlocLocalizationsDelegate(
  localizations: {Locale('pt'): const FormBlocLocalizationsPt()},
)

Cross-Field Validation #

Override the crossValidators getter in your FormBloc to validate fields relative to each other. Cross-validators run after all individual field validators pass, just before submission.

class RegisterBloc extends FormBloc {
  final password = TextFieldBloc(
    name: 'password',
    validators: [Validators.required, Validators.password],
  );
  final confirmPassword = TextFieldBloc(
    name: 'confirm_password',
    validators: [Validators.required],
  );
  final startDate = InputFieldBloc<DateTime?, dynamic>(name: 'start_date');
  final endDate = InputFieldBloc<DateTime?, dynamic>(name: 'end_date');

  RegisterBloc() {
    addFieldBlocs(fieldBlocs: [password, confirmPassword, startDate, endDate]);
  }

  @override
  List<Map<String, String?> Function(FormBlocState)> get crossValidators => [
    // Password match
    (state) {
      final pwd = state.fieldByName('password');
      final confirm = state.fieldByName('confirm_password');
      if (pwd?.value != null && pwd?.value != confirm?.value) {
        return {'confirm_password': 'Passwords do not match'};
      }
      return {};
    },
    // Date range
    (state) {
      final start = state.fieldByName('start_date');
      final end = state.fieldByName('end_date');
      final startVal = start?.value as DateTime?;
      final endVal = end?.value as DateTime?;
      if (startVal != null && endVal != null && endVal.isBefore(startVal)) {
        return {'end_date': 'End date must be after start date'};
      }
      return {};
    },
  ];

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async {
    // Cross-validators run automatically before this is called
    await apiProvider.post(path: 'register', body: {
      'password': password.value,
    });
    emitSuccessCreation(emit);
  }
}

Form Reset #

Reset all fields to their initial values (empty), or reset errors while keeping current values:

// Reset everything — clears values and errors
bloc.resetForm();

// Keep values but clear errors and reset status
bloc.resetForm(keepValues: true);

In the UI you can listen with FormBlocListener and trigger reset on a button press:

OutlinedButton(
  onPressed: () => bloc.resetForm(),
  child: const Text('Reset'),
),

Validation Debounce #

Avoid running validators on every single keystroke by configuring a debounce duration:

class SearchFormBloc extends FormBloc {
  final query = TextFieldBloc(
    name: 'query',
    validators: [Validators.required],
  );

  SearchFormBloc()
      : super(validationDebounce: const Duration(milliseconds: 300)) {
    addFieldBlocs(fieldBlocs: [query]);
  }

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async { /* ... */ }
}

The UI remains responsive (values update immediately) but validation only triggers after the user pauses typing.

Multi-Step Forms #

class MultiStepFormBloc extends FormBloc {
  // Step 1 fields
  final firstName = TextFieldBloc(name: 'first_name', validators: [Validators.required], step: 1);
  final lastName = TextFieldBloc(name: 'last_name', validators: [Validators.required], step: 1);
  
  // Step 2 fields
  final email = TextFieldBloc(name: 'email', validators: [Validators.email], step: 2);
  final phone = TextFieldBloc(name: 'phone', step: 2);
  
  // Step 3 fields
  final address = TextFieldBloc(name: 'address', step: 3);
  final city = TextFieldBloc(name: 'city', step: 3);

  MultiStepFormBloc() {
    addFieldBlocs(fieldBlocs: [firstName, lastName, email, phone, address, city]);
  }
  
  bool validateCurrentStep(int step) {
    return validateStep(step);
  }

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async {
    // Submit all form data
    final formData = {
      'first_name': firstName.value,
      'last_name': lastName.value,
      'email': email.value,
      'phone': phone.value,
      'address': address.value,
      'city': city.value,
    };
    
    try {
      await apiProvider.post(path: 'users', body: formData);
      emitSuccessCreation(emit);
    } catch (e) {
      emitFailure(emit, failureResponse: e);
    }
  }
}

Lists and Pagination #

Basic List Implementation #

class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json['id'],
    name: json['name'],
    email: json['email'],
  );

  static Map<String, dynamic> toJson(User user) => {
    'id': user.id,
    'name': user.name,
    'email': user.email,
  };
}

// Standard paginated list (default behavior)
class UserListBloc extends ListBloc<User> {
  UserListBloc() : super(
    provider: IwsHttpModel(
      path: 'users',
      iwsHttp: IwsHttp(),
      toJson: User.toJson,
      fromJson: User.fromJson,
    ),
    usePagination: true, // Default: true, sends 'page' parameter to API
  );
}

// Non-paginated list (for endpoints that return all data at once)
class AllUsersListBloc extends ListBloc<User> {
  AllUsersListBloc() : super(
    provider: IwsHttpModel(
      path: 'users/all',
      iwsHttp: IwsHttp(),
      toJson: User.toJson,
      fromJson: User.fromJson,
    ),
    usePagination: false, // No 'page' parameter sent to API
  );
}

// List with caching support
class CachedUserListBloc extends ListBloc<User> {
  CachedUserListBloc() : super(
    provider: IwsHttpModel(
      path: 'users',
      iwsHttp: IwsHttp(),
      toJson: User.toJson,
      fromJson: User.fromJson,
    ),
    cacheConfig: CacheConfig(
      cache: IwsMemoryCache(), // or IwsPersistentCache()
      ttl: Duration(minutes: 15), // Cache for 15 minutes
    ),
  );
}

Pagination Control with usePagination Parameter

The ListBloc provides flexible pagination control through the usePagination parameter:

When usePagination: true (default):

  • Standard pagination behavior
  • Sends page parameter to the API
  • Supports infinite scrolling
  • Accumulates data across pages

When usePagination: false:

  • Single request for all data
  • No page parameter 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.dart for cache implementations like IwsMemoryCache() and IwsPersistentCache().

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, getting
    • deleting, deleted for item deletion
    • processing, processingSuccess for 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 calls
    • customPath: Override the default endpoint path
    • apiKey and auth: Authentication options
    • queryParameters: 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));
  • 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 hasReachedMax to 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

    • PaginatedListNextPage and PaginatedListPreviousPage start from page 1 when called initially
    • PaginatedListGoToPage goes 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());
}
// ❌ Old (loadFaild — removed in v5.0.0)
// FormBlocConsumer<MyBloc>(
//   loadFaild: const Center(child: Text('Error loading')),
// )

// ✅ v4.0.0+ / v5.0.0
FormBlocConsumer<MyBloc>(
  loadFailed: const Center(child: Text('Error loading')),
  // ...
)

Check: SearchState new fields #

If you have code that compares SearchState instances or uses copyWith, note that SearchState now includes two new fields with defaults — no action required unless you subclass SearchState:

// These are now available:
state.queryParameters  // Map<String, String>? — active query params
state.currentPage      // int — current page (default: 1)

// Update search results with new filters:
bloc.add(SearchRefresh(queryParameters: {'status': 'active'}));

No action needed: Performance improvements #

buildWhen is now built into all field builders and O(1) field lookup is automatic — no code changes required to benefit.

New opt-in features #

All new v4.0.0 features (resetForm, crossValidators, validationDebounce, RadioGroupFieldBlocBuilder, SliderFieldBlocBuilder, SelectFieldBloc.itemsLoader) are purely additive and opt-in. Existing code continues to work unchanged.

Best Practices #

  1. Form Organization: Group related fields logically and use meaningful field names
  2. Validation Strategy: Combine client-side validation with server-side validation
  3. State Management: Use FormBlocConsumer for handling form states and user feedback
  4. Performance: Use BlocBuilder selectively to rebuild only necessary widgets
  5. Accessibility: Provide proper labels, hints, and error messages for screen readers
  6. 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);
    });
  });
}
0
likes
150
points
597
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Simplified creation of forms and listings using the BLoC pattern.

Homepage

License

MIT (license)

Dependencies

bloc, equatable, flutter, flutter_bloc, iws_cache, iws_http, iws_http_model, material_ui

More

Packages that depend on iws_form_bloc