IWS Form BLoC Package

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

Features

  • Type-safe form fields: Various field types including text, password, boolean, select, multi-select, date, and more
  • Built-in validation: Pre-built validators for common use cases (email, password, required, etc.)
  • Reactive forms: Real-time validation and state updates using BLoC pattern
  • HTTP integration: Seamless integration with REST APIs through IWS HTTP
  • HTTP caching: Built-in caching support for improved performance and offline capabilities
  • Paginated lists: Built-in support for paginated data with infinite scrolling and configurable pagination behavior
  • Advanced pagination: PaginatedListBloc with granular navigation control (next, previous, goto page), built-in validation, and bidirectional page navigation
  • Search functionality: Advanced search capabilities with pagination
  • Custom field builders: Ready-to-use UI components for all field types
  • Multi-step forms: Support for wizard-style forms with step validation
  • Error handling: Comprehensive error handling with field-specific error messages
  • Loading states: Built-in loading indicators and state management

Installation

Add this to your package's pubspec.yaml file:

dependencies:
  iws_form_bloc: ^latest_version

🚀 What's New in Pagination

The PaginatedListBloc has been enhanced with more intuitive navigation events:

Migration from Previous Versions

// ❌ Old way (deprecated)
bloc.add(PaginatedListFetched());           // Initial load
bloc.add(PaginatedListFetched(page: 2));    // Go to page 2
bloc.add(PaginatedListFetched(page: null)); // Next page

// ✅ New way (recommended)
bloc.add(PaginatedListGoToPage(page: 1));   // Initial load or go to page 1
bloc.add(PaginatedListGoToPage(page: 2));   // Go to page 2
bloc.add(PaginatedListNextPage());          // Next page
bloc.add(PaginatedListPreviousPage());      // Previous page (new!)

Benefits of the new approach:

  • Clearer intent: Each event clearly expresses the navigation action
  • Better UX: Backward navigation is now possible
  • Built-in validation: Prevents invalid page navigation automatically
  • Type safety: Required parameters where needed, optional where not

Quick Start

1. Import the package

import 'package:iws_form_bloc/iws_form_bloc.dart';

Form Implementation

Creating a Form BLoC

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

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

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

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

Using Form Widgets

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

Field Types and Builders

Text Field

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

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

Password Field

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

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

Boolean/Checkbox Field

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

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

Select/Dropdown Field

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

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

Multi-Select Field

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

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

Date Field

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

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

List Tile Select Field

// 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
  },
)

Calendar Widget

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

Validation

Built-in Validators

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

Custom Validators

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

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

Multi-Step Forms

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

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

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

Lists and Pagination

Basic List Implementation

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

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

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

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

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

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

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

Pagination Control with usePagination Parameter

The ListBloc provides flexible pagination control through the usePagination parameter:

When usePagination: true (default):

  • Standard pagination behavior
  • Sends 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.

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);
    });
  });
}

Libraries

iws_form_bloc