Taming the Beast: Mastering Forms in Flutter

Taming the Beast: Mastering Forms in Flutter

Crafting Elegant Forms with Riverpod, Hooks, and the Magic of Freezed

Featured on Hashnode

Ever found yourself pulling your hair out over form handling in your apps?

Trust me, I've been there. Forms can be a real pain, but they're also the bread and butter of most apps. So, let's roll up our sleeves and dive into the world of Flutter forms together. By the end of this, you'll be creating forms so smooth, users might actually enjoy filling them out.

Setting the Stage

First things first, we need to invite some friends to our form-building party.

Open up your pubspec.yaml file and add these cool cats:

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.20.5
  hooks_riverpod: ^2.5.2
  riverpod_annotation: ^2.3.5
  freezed_annotation: ^2.4.4 
  json_annotation: ^4.9.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.9
  riverpod_generator: ^2.4.0
  freezed: ^2.5.2
  json_serializable: ^6.8.0

Now, run flutter pub get to fetch these packages. It's like ordering pizza for your project – essential for a good time!

But why these specific packages, you ask?

Well, flutter_hooks is like a Swiss Army knife for state management in widgets. hooks_riverpod combines the power of Riverpod (our state management superhero) with Flutter Hooks. And riverpod_annotation with riverpod_generator? They're the dynamic duo that'll help us write less boilerplate code.

The VIP: UserProfile Class

Let's create our star player, the UserProfile class. Think of it as the VIP of our app – it's got all the important info:

class UserProfile {
  final String username;
  final String email;
  final int age;
  final bool notificationsEnabled;

  UserProfile({
    required this.username,
    required this.email,
    required this.age,
    required this.notificationsEnabled,
  });

  UserProfile copyWith({
    String? username,
    String? email,
    int? age,
    bool? notificationsEnabled,
  }) {
    return UserProfile(
      username: username ?? this.username,
      email: email ?? this.email,
      age: age ?? this.age,
      notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
    );
  }
}

See that copyWith method? It's like a clone machine for our UserProfile. Super handy when we want to update just one or two fields!

Do we really need to write this by hand?

Now, let's rewrite our UserProfile class using Freezed:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_profile.freezed.dart';
part 'user_profile.g.dart';

@freezed
class UserProfile with _$UserProfile {
  const factory UserProfile({
    required String username,
    required String email,
    required int age,
    @Default(true) bool notificationsEnabled,
  }) = _UserProfile;

  factory UserProfile.fromJson(Map<String, dynamic> json) => _$UserProfileFromJson(json);
}

Whoa, what just happened? We've turned our UserProfile into a Freezed class. It's like giving it superpowers:

  1. Immutability: This class is now as unchangeable as your grandma's secret recipe.

  2. Auto-generated copyWith: No more hand-writing clone methods!

  3. Equality comparisons: Two profiles with the same data are now considered equal. Identity crisis averted!

  4. JSON superpowers: Serialization and deserialization come built-in. API integration just got a whole lot easier!

  5. Pattern matching: For when you want to get fancy with your data handling.

The Personal Assistant: UserProfileNotifier

Now, we need someone to manage our VIP. Enter the UserProfileNotifier:

import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/user_profile.dart';

part 'user_profile_provider.g.dart';
@riverpod
class UserProfileNotifier extends _$UserProfileNotifier {
  @override
  UserProfile build() {
    return const UserProfile(
      username: 'JohnDoe',
      email: 'john@example.com',
      age: 30,
      notificationsEnabled: true,
    );
  }

  void updateProfile({
    String? username,
    String? email,
    int? age,
    bool? notificationsEnabled,
  }) {
    state = state.copyWith(
      username: username ?? state.username,
      email: email ?? state.email,
      age: age ?? state.age,
      notificationsEnabled: notificationsEnabled ?? state.notificationsEnabled,
    );
  }
}

This notifier is like a personal assistant for our UserProfile. It keeps track of changes and updates the profile when needed. The @riverpod annotation is telling our code generator, "Hey, make some cool stuff for this class!"

After creating this file, don't forget to run:

It's like telling your assistant, "Hey, organize all this stuff for me!"

flutter pub run build_runner build --delete-conflicting-outputs

The Main Event: ProfileSettingsForm

Alright, now for the main event – the form itself. We'll create a ProfileSettingsForm widget. This is where users can tweak their profile info:

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../providers/user_profile_provider.dart';

class ProfileSettingsForm extends HookConsumerWidget {
  const ProfileSettingsForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userProfile = ref.watch(userProfileNotifierProvider);
    final notifier = ref.read(userProfileNotifierProvider.notifier);

    final usernameController = useTextEditingController(text: userProfile.username);
    final emailController = useTextEditingController(text: userProfile.email);
    final ageController = useTextEditingController(text: userProfile.age.toString());
    final notificationsEnabled = useState(userProfile.notificationsEnabled);

    // We'll add more code here soon!

    return Scaffold(
      appBar: AppBar(title: Text('Profile Settings')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextFormField(
              controller: usernameController,
              decoration: InputDecoration(labelText: 'Username'),
            ),
            TextFormField(
              controller: emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextFormField(
              controller: ageController,
              decoration: InputDecoration(labelText: 'Age'),
              keyboardType: TextInputType.number,
            ),
            SwitchListTile(
              title: Text('Enable Notifications'),
              value: notificationsEnabled.value,
              onChanged: (value) => notificationsEnabled.value = value,
            ),
            // We'll add a save button here soon!
          ],
        ),
      ),
    );
  }
}

Notice how we're using copyWith in the saveProfile method? That's our Freezed magic making profile updates a breeze!

The Freezed Cherry on Top

After making all these cool changes, don't forget to tell your project to generate all the magical code:

flutter pub run build_runner build --delete-conflicting-outputs

This form is like a backstage pass to the user's profile. We're using HookConsumerWidget because it's the cool kid that lets us use both Riverpod and Hooks in the same widget.

The Watchful Eye: Tracking Form Changes

We want to know when the user makes changes, right? Let's set up a little spy system:

final isFormChanged = useState(false);

useEffect(() {
  void listener() {
    isFormChanged.value = usernameController.text != userProfile.username ||
        emailController.text != userProfile.email ||
        ageController.text != userProfile.age.toString() ||
        notificationsEnabled.value != userProfile.notificationsEnabled;
  }

  usernameController.addListener(listener);
  emailController.addListener(listener);
  ageController.addListener(listener);

  return () {
    usernameController.removeListener(listener);
    emailController.removeListener(listener);
    ageController.removeListener(listener);
  };
}, [userProfile]);

This useEffect hook is like a vigilant guard, always watching for changes in the form. It's comparing the current values to the original ones, and if anything's different, it raises the isFormChanged flag.

The Bouncer: Input Validation

We can't just let any old data through, can we? Time for some validation:

bool validateInputs() {
  final isValidEmail = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(emailController.text);
  final isValidAge = int.tryParse(ageController.text) != null && int.parse(ageController.text) > 0;
  return usernameController.text.isNotEmpty && isValidEmail && isValidAge;
}

This function is like the bouncer at an exclusive club. It's checking if the email looks legit, the age is a positive number, and the username isn't empty. Only the cool kids (valid inputs) get through!

The Grand Finale: Saving the Profile

When the user hits that save button, we want to make sure everything's shipshape:

void saveProfile() {
  if (!validateInputs()) return;

  notifier.updateProfile(
    username: usernameController.text,
    email: emailController.text,
    age: int.parse(ageController.text),
    notificationsEnabled: notificationsEnabled.value,
  );

  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text('Profile updated successfully')),
  );

  isFormChanged.value = false;
}

This function is like a careful librarian, making sure all the info is filed away correctly. If everything checks out, it updates the profile, shows a snazzy success message, and resets our change tracker.

Now, let's add that save button to our form:

ElevatedButton(
  onPressed: isFormChanged.value && validateInputs() ? saveProfile : null,
  child: Text('Save Profile'),
)

This button is smart - it only lights up when there are changes and all inputs are valid. No premature saving here!

Everything Together

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../providers/user_profile_provider.dart';

class ProfileSettingsForm extends HookConsumerWidget {
  const ProfileSettingsForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userProfile = ref.watch(userProfileNotifierProvider);
    final notifier = ref.read(userProfileNotifierProvider.notifier);

    final usernameController = useTextEditingController(text: userProfile.username);
    final emailController = useTextEditingController(text: userProfile.email);
    final ageController = useTextEditingController(text: userProfile.age.toString());
    final notificationsEnabled = useState(userProfile.notificationsEnabled);

    final isFormChanged = useState(false);

    useEffect(() {
      void listener() {
        isFormChanged.value = usernameController.text != userProfile.username ||
            emailController.text != userProfile.email ||
            ageController.text != userProfile.age.toString() ||
            notificationsEnabled.value != userProfile.notificationsEnabled;
      }

      usernameController.addListener(listener);
      emailController.addListener(listener);
      ageController.addListener(listener);

      return () {
        usernameController.removeListener(listener);
        emailController.removeListener(listener);
        ageController.removeListener(listener);
      };
    }, [userProfile]);

    bool validateInputs() {
      final isValidEmail = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(emailController.text);
      final isValidAge = int.tryParse(ageController.text) != null && int.parse(ageController.text) > 0;
      return usernameController.text.isNotEmpty && isValidEmail && isValidAge;
    }

    void saveProfile() {
      if (!validateInputs()) return;

      final updatedProfile = userProfile.copyWith(
        username: usernameController.text,
        email: emailController.text,
        age: int.parse(ageController.text),
        notificationsEnabled: notificationsEnabled.value,
      );

      notifier.updateProfile(
        username: updatedProfile.username,
        email: updatedProfile.email,
        age: updatedProfile.age,
        notificationsEnabled: updatedProfile.notificationsEnabled,
      );

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Profile updated successfully')),
      );

      isFormChanged.value = false;
    }

    return Scaffold(
      appBar: AppBar(title: Text('Profile Settings')),
      body: Padding(
        padding: EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextFormField(
              controller: usernameController,
              decoration: InputDecoration(labelText: 'Username'),
            ),
            TextFormField(
              controller: emailController,
              decoration: InputDecoration(labelText: 'Email'),
            ),
            TextFormField(
              controller: ageController,
              decoration: InputDecoration(labelText: 'Age'),
              keyboardType: TextInputType.number,
            ),
            SwitchListTile(
              title: Text('Enable Notifications'),
              value: notificationsEnabled.value,
              onChanged: (value) => notificationsEnabled.value = value,
            ),
            ElevatedButton(
              onPressed: isFormChanged.value && validateInputs() ? saveProfile : null,
              child: Text('Save Profile'),
            ),
          ],
        ),
      ),
    );
  }
}

Pro Tips for Form Mastery

  1. Debounce like a Pro: If you're doing something heavy (like API calls) on every keystroke, use debouncing. It's like giving your app a chill pill:

        final debouncedUsername = useDebounce(usernameController.text, const Duration(milliseconds: 300));
    
        useEffect(() {
          print('Checking availability for username: $debouncedUsername');
          // Perform API call here
          return null;
        }, [debouncedUsername]);
    
  2. Error Messages with Attitude: Don't just say "Invalid input". Get creative!

        TextFormField(
          controller: emailController,
          decoration: InputDecoration(
            labelText: 'Email',
            errorText: !RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(emailController.text)
                ? 'That email looks about as real as a three-dollar bill'
                : null,
          ),
        )
    
  3. Accessibility is Key: Make your forms accessible. It's not just nice, it's necessary:

        TextFormField(
          controller: usernameController,
          decoration: InputDecoration(labelText: 'Username'),
          autofocus: true, // Focus on this field when the form loads
          textInputAction: TextInputAction.next, // Move to next field on submit
        )
    
  4. Form Keys: For more complex forms, use a GlobalKey<FormState>:

     final formKey = GlobalKey<FormState>();
    
     // In your build method:
     Form(
       key: formKey,
       child: Column(
         children: [
           // Your form fields here
         ],
       ),
     )
    
     // Validate the entire form:
     if (formKey.currentState!.validate()) {
       // Form is valid, proceed with saving
     }
    
  5. Separation of Concerns: Use Riverpod providers to handle business logic:

     @riverpod
     Future<bool> checkUsernameAvailability(CheckUsernameAvailabilityRef ref, String username) async {
       // Implement API call to check username availability
       await Future.delayed(Duration(seconds: 1)); // Simulating API call
       return username != 'takenUsername';
     }
    
  6. Test, Test, Test: Write tests for your forms. It's like proofreading your work – boring but essential:

        testWidgets('ProfileSettingsForm shows validation errors', (WidgetTester tester) async {
          await tester.pumpWidget(ProviderScope(child: MaterialApp(home: ProfileSettingsForm())));
    
          await tester.enterText(find.byType(TextFormField).at(1), 'invalid-email');
          await tester.pump();
    
          expect(find.text('That email looks about as real as a three-dollar bill'), findsOneWidget);
        });
    

    Wrapping Up

    And there you have it, folks! We've taken the scary beast that is form handling in Flutter and tamed it into a purring kitten. With Riverpod for state management and hooks for local widget state, you've got a powerful combo that'll make your forms sing.

    Remember, practice makes perfect. The more forms you build, the easier it gets. So go forth and create forms that are so good, users might actually enjoy filling them out. (Okay, maybe that's a stretch, but we can dream, right?)

    Happy coding, and may your forms always validate on the first try!

Did you find this article valuable?

Support Temitope Ajiboye by becoming a sponsor. Any amount is appreciated!