Taming the Beast: Mastering Forms in Flutter
Crafting Elegant Forms with Riverpod, Hooks, and the Magic of Freezed
Table of contents
- Setting the Stage
- The VIP: UserProfile Class
- The Personal Assistant: UserProfileNotifier
- The Main Event: ProfileSettingsForm
- The Freezed Cherry on Top
- The Watchful Eye: Tracking Form Changes
- The Bouncer: Input Validation
- The Grand Finale: Saving the Profile
- Everything Together
- Pro Tips for Form Mastery
- Wrapping Up
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:
Immutability: This class is now as unchangeable as your grandma's secret recipe.
Auto-generated copyWith: No more hand-writing clone methods!
Equality comparisons: Two profiles with the same data are now considered equal. Identity crisis averted!
JSON superpowers: Serialization and deserialization come built-in. API integration just got a whole lot easier!
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
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]);
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, ), )
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 )
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 }
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'; }
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!