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

unlisted

Simplified creation of forms and listings using the BLoC pattern.

IWS Form BLoC Package #

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

Features #

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

Installation #

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

dependencies:
  iws_form_bloc: ^latest_version

Quick Start #

1. Import the package #

import 'package:iws_form_bloc/iws_form_bloc.dart';

Form Implementation #

Creating a Form BLoC #

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

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

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

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

Using Form Widgets #

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

Field Types and Builders #

Text Field #

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

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

Password Field #

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

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

Boolean/Checkbox Field #

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

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

Select/Dropdown Field #

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

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

Multi-Select Field #

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

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

Date Field #

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

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

Calendar Widget #

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

Validation #

Built-in Validators #

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

Custom Validators #

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

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

Multi-Step Forms #

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

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

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

Lists and Pagination #

Basic List Implementation #

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

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

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

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

class UserListBloc extends ListBloc<User> {
  UserListBloc() : super(
    provider: IwsHttpModel(
      path: 'users',
      iwsHttp: IwsHttp(),
      toJson: User.toJson,
      fromJson: User.fromJson,
    ),
  );
}

List Widget with Infinite Scroll #

class UserListPage extends StatefulWidget {
  @override
  State<UserListPage> createState() => _UserListPageState();
}

class _UserListPageState extends State<UserListPage> {
  late UserListBloc _bloc;
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _bloc = UserListBloc()..add(ListFetched());
    _scrollController.addListener(_onScroll);
  }

  void _onScroll() {
    if (_isBottom) _bloc.add(ListFetched());
  }

  bool get _isBottom {
    if (!_scrollController.hasClients) return false;
    final maxScroll = _scrollController.position.maxScrollExtent;
    final currentScroll = _scrollController.offset;
    return currentScroll >= (maxScroll * 0.9);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: BlocProvider(
        create: (context) => _bloc,
        child: BlocBuilder<UserListBloc, ListState<User>>(
          builder: (context, state) {
            switch (state.status) {
              case ListStatus.failure:
                return Center(child: Text('Error: ${state.error?.error}'));
                
              case ListStatus.success:
              case ListStatus.getting:
                return RefreshIndicator(
                  onRefresh: () async {
                    _bloc.add(ListRefresh());
                  },
                  child: ListView.builder(
                    controller: _scrollController,
                    itemCount: state.hasReachedMax 
                        ? state.list.length 
                        : state.list.length + 1,
                    itemBuilder: (context, index) {
                      if (index >= state.list.length) {
                        return const Center(child: CircularProgressIndicator());
                      }
                      
                      final user = state.list[index];
                      return ListTile(
                        leading: CircleAvatar(child: Text(user.id.toString())),
                        title: Text(user.name),
                        subtitle: Text(user.email),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete),
                          onPressed: () => _bloc.add(ListItemDeleted(user.id)),
                        ),
                      );
                    },
                  ),
                );
                
              case ListStatus.initial:
                return const Center(child: CircularProgressIndicator());
                
              default:
                return const SizedBox();
            }
          },
        ),
      ),
    );
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }
}

Search Functionality #

Search BLoC Implementation #

class UserSearchBloc extends SearchBloc<User> {
  UserSearchBloc() : super(
    provider: IwsHttpModel(
      path: 'users/search',
      iwsHttp: IwsHttp(),
      toJson: User.toJson,
      fromJson: User.fromJson,
    ),
  );
}

Search Widget #

class UserSearchPage extends StatefulWidget {
  @override
  State<UserSearchPage> createState() => _UserSearchPageState();
}

class _UserSearchPageState extends State<UserSearchPage> {
  late UserSearchBloc _searchBloc;
  final _searchController = TextEditingController();
  Timer? _debounce;

  @override
  void initState() {
    super.initState();
    _searchBloc = UserSearchBloc();
    _searchController.addListener(_onSearchChanged);
  }

  void _onSearchChanged() {
    if (_debounce?.isActive ?? false) _debounce!.cancel();
    _debounce = Timer(const Duration(milliseconds: 500), () {
      if (_searchController.text.isNotEmpty) {
        _searchBloc.add(SearchFetched(
          queryParameters: {'q': _searchController.text},
        ));
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          controller: _searchController,
          decoration: const InputDecoration(
            hintText: 'Search users...',
            border: InputBorder.none,
            suffixIcon: Icon(Icons.search),
          ),
        ),
      ),
      body: BlocProvider(
        create: (context) => _searchBloc,
        child: BlocBuilder<UserSearchBloc, SearchState<User>>(
          builder: (context, state) {
            switch (state.status) {
              case SearchStatus.searching:
                return const Center(child: CircularProgressIndicator());
                
              case SearchStatus.success:
                return ListView.builder(
                  itemCount: state.list.length,
                  itemBuilder: (context, index) {
                    final user = state.list[index];
                    return ListTile(
                      title: Text(user.name),
                      subtitle: Text(user.email),
                    );
                  },
                );
                
              case SearchStatus.failure:
                return Center(child: Text('Search failed: ${state.error?.error}'));
                
              default:
                return const Center(child: Text('Start typing to search...'));
            }
          },
        ),
      ),
    );
  }

  @override
  void dispose() {
    _searchController.dispose();
    _debounce?.cancel();
    super.dispose();
  }
}

Advanced Features #

Custom Field Type #

class ColorFieldBloc extends FormFieldBloc<Color, dynamic> {
  ColorFieldBloc({required super.name, super.validators, super.step})
      : super(initialValue: Colors.blue);
}

// Custom widget builder
class ColorFieldBlocBuilder<BlocType extends FormBloc> extends StatelessWidget {
  final FormBloc bloc;
  final ColorFieldBloc blocField;
  final String label;

  const ColorFieldBlocBuilder({
    super.key,
    required this.bloc,
    required this.blocField,
    required this.label,
  });

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<BlocType, FormBlocState>(
      bloc: bloc as BlocType,
      builder: (context, state) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(label),
            const SizedBox(height: 8),
            GestureDetector(
              onTap: () => _showColorPicker(context),
              child: Container(
                width: 50,
                height: 50,
                decoration: BoxDecoration(
                  color: blocField.value,
                  border: Border.all(color: Colors.grey),
                  borderRadius: BorderRadius.circular(8),
                ),
              ),
            ),
            if (blocField.error != null)
              Padding(
                padding: const EdgeInsets.only(top: 4),
                child: Text(
                  blocField.error!,
                  style: const TextStyle(color: Colors.red, fontSize: 12),
                ),
              ),
          ],
        );
      },
    );
  }

  void _showColorPicker(BuildContext context) {
    // Implement color picker dialog
  }
}

Form with File Upload #

class ProfileFormBloc extends FormBloc {
  final name = TextFieldBloc(name: 'name', validators: [Validators.required]);
  final bio = TextFieldBloc(name: 'bio');
  final avatar = InputFieldBloc<File?, dynamic>(name: 'avatar');

  ProfileFormBloc() {
    addFieldBlocs(fieldBlocs: [name, bio, avatar]);
  }

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async {
    try {
      final formData = FormData.fromMap({
        'name': name.value,
        'bio': bio.value,
        if (avatar.value != null) 
          'avatar': await MultipartFile.fromFile(avatar.value!.path),
      });

      await apiProvider.postFormData(path: 'profile', formData: formData);
      emitSuccessModification(emit);
    } catch (e) {
      emitFailure(emit, failureResponse: e);
    }
  }
}

Dynamic Form Fields #

class DynamicFormBloc extends FormBloc {
  final List<TextFieldBloc> dynamicFields = [];

  DynamicFormBloc() {
    _addInitialFields();
  }

  void _addInitialFields() {
    final field = TextFieldBloc(name: 'field_0');
    dynamicFields.add(field);
    addFieldBlocs(fieldBlocs: [field]);
  }

  void addNewField() {
    final field = TextFieldBloc(name: 'field_${dynamicFields.length}');
    dynamicFields.add(field);
    
    final allFields = [...state.fieldBlocs, field];
    add(FormBlocAddFields(allFields));
  }

  void removeField(int index) {
    if (dynamicFields.length > 1) {
      dynamicFields.removeAt(index);
      final remainingFields = state.fieldBlocs.where(
        (field) => field.name != 'field_$index'
      ).toList();
      add(FormBlocAddFields(remainingFields));
    }
  }

  @override
  void onSubmitting(Emitter<FormBlocState> emit) async {
    final fieldData = dynamicFields.map((field) => field.value).toList();
    // Submit dynamic field data
  }
}

Error Handling #

Field-Specific Errors #

// Add error to specific field
bloc.addFormError('This email is already taken', fieldBloc: bloc.emailField);

// Add general form error
bloc.addFormError('Server is temporarily unavailable');

API Error Handling #

@override
void onSubmitting(Emitter<FormBlocState> emit) async {
  try {
    final response = await apiProvider.post(path: 'submit', body: formData);
    emitSuccess(emit, successResponse: response);
  } on ApiException catch (e) {
    if (e.statusCode == 422) {
      // Handle validation errors from API
      emitFailure(emit, failureResponse: FormErrorModel(
        message: e.error,
        errorInputs: e.validationErrors, // Maps field names to error messages
      ));
    } else {
      emitFailure(emit, failureResponse: FormErrorModel(message: e.error));
    }
  } catch (e) {
    emitFailure(emit, failureResponse: FormErrorModel(
      message: 'An unexpected error occurred'
    ));
  }
}

Best Practices #

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

License #

This project is licensed under the MIT License - see the LICENSE file for details.

0
likes
0
points
597
downloads

Publisher

unverified uploader

Weekly Downloads

Simplified creation of forms and listings using the BLoC pattern.

Homepage

License

unknown (license)

Dependencies

bloc, flutter, flutter_bloc, iws_http, iws_http_model

More

Packages that depend on iws_form_bloc