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

Simplified creation of forms and listings using the BLoC pattern.

IWS Form BLoC Package #

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

Features #

  • Type-safe form fields: Various field types including text, password, boolean, select, multi-select, date, and more
  • Built-in validation: Pre-built validators for common use cases (email, password, required, etc.)
  • Reactive forms: Real-time validation and state updates using BLoC pattern
  • HTTP integration: Seamless integration with REST APIs through IWS HTTP
  • HTTP caching: Built-in caching support for improved performance and offline capabilities
  • Paginated lists: Built-in support for paginated data with infinite scrolling and configurable pagination behavior
  • Advanced pagination: 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,
)

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

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

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Simplified creation of forms and listings using the BLoC pattern.

Homepage

License

MIT (license)

Dependencies

bloc, flutter, flutter_bloc, iws_cache, iws_http, iws_http_model

More

Packages that depend on iws_form_bloc