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

unlisted

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

Deprecated #

  • FormBlocConsumer(loadFaild: ...) → use loadFailed: instead

Quick Start #

1. Import the package #

import 'package:iws_form_bloc/iws_form_bloc.dart';

Form Implementation #

Creating a Form BLoC #

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

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

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

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

Using Form Widgets #

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

Field Types and Builders #

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

See FIELD_WIDGETS.md for detailed documentation on:

  • TextFieldBloc: Text input, password input
  • BooleanFieldBloc: Checkboxes, segmented buttons
  • SelectFieldBloc: Dropdown menus, radio groups, async loading
  • MultiSelectFieldBloc: Multi-selection with chips
  • InputFieldBloc: Date pickers, calendars, sliders, list tile selection

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

Key Features:

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

Validation #

Built-in Validators #

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

Custom Validators #

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

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

Internationalization (i18n) #

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

Option A — Fixed language at startup #

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

import 'package:iws_form_bloc/iws_form_bloc.dart';

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

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

  runApp(const MyApp());
}

Option B — Reactive to device/app locale #

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

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

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

Pass a custom map to support additional languages:

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

Custom Language #

Implement FormBlocLocalizations to provide any language:

class FormBlocLocalizationsPt extends FormBlocLocalizations {
  const FormBlocLocalizationsPt();

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

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

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

Cross-Field Validation #

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

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

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

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

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

Form Reset #

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

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

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

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

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

Validation Debounce #

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

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

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

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

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

Multi-Step Forms #

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

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

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

Lists and Pagination #

Basic List Implementation #

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

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

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

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

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

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

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

Pagination Control with usePagination Parameter

The ListBloc provides flexible pagination control through the usePagination parameter:

When usePagination: true (default):

  • Standard pagination behavior
  • Sends 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());
}
// ❌ v3.x — still works but @Deprecated
FormBlocConsumer<MyBloc>(
  loadFaild: const Center(child: Text('Error loading')),
  // ...
)

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

Check: SearchState new fields #

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

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

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

No action needed: Performance improvements #

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

New opt-in features #

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

Best Practices #

  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
0
points
597
downloads

Publisher

unverified uploader

Weekly Downloads

Simplified creation of forms and listings using the BLoC pattern.

Homepage

License

unknown (license)

Dependencies

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

More

Packages that depend on iws_form_bloc