A robust, "smart" form management package for Flutter that handles validation, auto-saving (draft restoration), and analytics out of the box.
SmartFormGuard decouples your form logic from the UI, ensuring your forms are responsive, persistent, and insightful. It is designed to be architecture-agnostic, working seamlessly with Provider, Riverpod, Bloc, or vanilla setState.
- π§ Smart State Management: Decoupled form logic using
FormController. - πΎ Auto-Save & Restore: Automatically saves drafts locally and restores them on app restart. Never lose user input again!
- π Analytics: Tracks submission attempts and identifies which fields cause the most errors (great for UX improvements).
- π‘οΈ GuardField: A smart wrapper around
TextFormFieldthat auto-registers with the controller. - π§© Custom Storage: Swap out the default
SharedPreferencesfor Hive, SecureStorage, or your own backend. - β¨ Complex Validators: Built-in support for numeric, phone, range, match (confirm password), and more.
Add the dependency to your pubspec.yaml:
dependencies:
flutter_form_guard: ^1.2.0Then run:
flutter pub getThe SmartFormGuard widget injects a FormController into the widget tree. This controller manages the state of all descendant GuardField widgets.
import 'package:flutter_form_guard/flutter_form_guard.dart';
SmartFormGuard(
formId: 'login_form', // Unique ID for auto-save persistence
child: Column(
children: [
// ... fields go here
],
),
)Use GuardField instead of TextFormField. It automatically hooks into the controller, handling validation and state updates.
GuardField.email(
name: 'email',
label: 'Email Address'
),
GuardField.password(
name: 'password',
label: 'Password'
),Access the controller to validate the form and retrieve data.
ElevatedButton(
onPressed: () {
// Get the controller from context
final controller = SmartFormGuard.of(context);
if (controller.validate()) {
// β
Form is valid
final email = controller.getField('email')?.value;
final password = controller.getField('password')?.value;
print("Logging in with $email");
// Reset after success if needed
// controller.reset();
} else {
// β Form is invalid
// Errors are automatically displayed on the fields
// Check analytics for debugging UX
print(controller.analytics);
}
},
child: Text('Login'),
)The brain of the operation. It maintains the state of fields (value, error, touched), handles validation logic, and manages auto-save timers. You typically don't instantiate this directly; SmartFormGuard creates it for you.
The dependency injector. It holds the FormController and provides it to descendants via InheritedWidget. It also manages the lifecycle of the controller (disposing it when the widget is removed).
The worker. It wraps a standard TextFormField and handles the two-way binding with the FormController. It listens for changes to update the UI and reports user input back to the controller.
flutter_form_guard comes with a suite of built-in validators in the Validators class. You can compose them using lists.
| Validator | Description | Usage |
|---|---|---|
required |
Ensures the field is not empty. | Validators.required() |
email |
Validates email format. | Validators.email() |
minLength |
Enforces minimum character count. | Validators.minLength(8) |
numeric |
Ensures input is a number. | Validators.numeric() |
range |
Checks if a number is within range. | Validators.range(18, 100) |
phone |
Basic phone number validation. | Validators.phone() |
match |
Checks if value matches another value. | Validators.match(() => otherValue) |
You can write your own validators easily. A validator is simply a function that takes a dynamic value and returns a String? error message (or null if valid).
String? myCustomValidator(dynamic value) {
if (value.toString().contains('forbidden')) {
return 'This word is not allowed';
}
return null;
}
// Usage
GuardField.text(
name: 'username',
validator: myCustomValidator,
)One of the most powerful features of SmartFormGuard is its ability to automatically save form state.
- How it works: When a field changes, a debounce timer starts. If no further changes occur within 500ms, the entire form state is saved to storage using the
formIdas the key. - Restoration: When
SmartFormGuardinitializes, it checks storage for existing data matching theformId. If found, it pre-populates the fields. - Configuration:
autoSave: Set tofalseto disable auto-saving.autoRestore: Set tofalseto disable auto-restoration.
SmartFormGuard(
formId: 'my_form',
autoSave: true, // Default
autoRestore: true, // Default
child: ...
)By default, SmartFormGuard uses SharedPreferences via the shared_preferences package. However, you can use any storage backend (Hive, FlutterSecureStorage, SQLite, or a remote API) by implementing the FormStorage interface.
import 'package:flutter_form_guard/flutter_form_guard.dart';
class MyHiveStorage implements FormStorage {
final Box box;
MyHiveStorage(this.box);
@override
Future<void> saveForm(String formId, Map<String, dynamic> data) async {
await box.put(formId, data);
}
@override
Future<Map<String, dynamic>?> getForm(String formId) async {
final data = box.get(formId);
return data != null ? Map<String, dynamic>.from(data) : null;
}
@override
Future<void> clearForm(String formId) async {
await box.delete(formId);
}
}
// Usage
SmartFormGuard(
formId: 'secure_form',
storage: MyHiveStorage(myBox), // Inject your custom storage
child: ...
)Testing forms is often painful. SmartFormGuard makes it easier by exposing the FormController.
You can test validation logic without rendering UI:
test('Login form validation', () {
final storage = MockStorage(); // Simple map-based mock
final controller = FormController(formId: 'test', storage: storage);
controller.registerField('email', validator: Validators.email());
controller.updateField('email', 'invalid-email');
expect(controller.validate(), false);
controller.updateField('email', 'valid@example.com');
expect(controller.validate(), true);
});In widget tests, you can interact with GuardField just like any other widget:
testWidgets('Form shows error on invalid submit', (tester) async {
await tester.pumpWidget(MyApp());
await tester.tap(find.text('Login')); // Submit empty form
await tester.pump();
expect(find.text('Required'), findsOneWidget); // Expect error message
});Contributions are welcome! Please read our contributing guide to learn how to propose bugfixes and new features.
This project is licensed under the MIT License - see the LICENSE file for details.