Created
March 24, 2026 08:55
-
-
Save PlugFox/eed8aaa9fe83a032f17139e8b1814986 to your computer and use it in GitHub Desktop.
Form State Management
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Form State Management | |
| * https://gist.github.com/PlugFox/eed8aaa9fe83a032f17139e8b1814986 | |
| * https://dartpad.dev/?id=eed8aaa9fe83a032f17139e8b1814986 | |
| */ | |
| import 'package:flutter/material.dart'; | |
| void main() { | |
| runApp(const App()); | |
| } | |
| class App extends StatelessWidget { | |
| const App({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| debugShowCheckedModeBanner: false, | |
| theme: ThemeData( | |
| colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), | |
| useMaterial3: true, | |
| ), | |
| home: const RegistrationPage(), | |
| ); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Custom ChangeNotifier — wraps a bio string with a character limit | |
| // --------------------------------------------------------------------------- | |
| class BioController extends ChangeNotifier { | |
| String _value = ''; | |
| static const int maxLength = 120; | |
| String get value => _value; | |
| int get remaining => maxLength - _value.length; | |
| bool get isValid => _value.isNotEmpty && _value.length <= maxLength; | |
| void update(String v) { | |
| _value = v; | |
| notifyListeners(); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // The form page | |
| // --------------------------------------------------------------------------- | |
| class RegistrationPage extends StatefulWidget { | |
| const RegistrationPage({super.key}); | |
| @override | |
| State<RegistrationPage> createState() => _RegistrationPageState(); | |
| } | |
| class _RegistrationPageState extends State<RegistrationPage> { | |
| // --- field controllers --- | |
| late final TextEditingController username; | |
| late final BioController bio; | |
| late final ValueNotifier<Set<String>> tags; | |
| late final ValueNotifier<bool> agreed; | |
| late final FocusNode usernameFocus; | |
| // --- form-level state --- | |
| late final ValueNotifier<bool> formValid; | |
| late final ValueNotifier<String?> formError; | |
| // --- single listenable for the whole form --- | |
| late final Listenable formController; | |
| static const _availableTags = ['Flutter', 'Dart', 'iOS', 'Android', 'Web']; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| username = TextEditingController(); | |
| bio = BioController(); | |
| tags = ValueNotifier({}); | |
| agreed = ValueNotifier(false); | |
| usernameFocus = FocusNode(); | |
| formValid = ValueNotifier(false); | |
| formError = ValueNotifier(null); | |
| // One listener for every field, including focus changes. | |
| formController = Listenable.merge([ | |
| username, | |
| bio, | |
| tags, | |
| agreed, | |
| usernameFocus, | |
| ]); | |
| formController.addListener(_onFormChanged); | |
| _onFormChanged(); // set initial state | |
| } | |
| @override | |
| void dispose() { | |
| formController.removeListener(_onFormChanged); | |
| // Dispose every controller in one pass. | |
| [ | |
| username, | |
| bio, | |
| tags, | |
| agreed, | |
| usernameFocus, | |
| ].whereType<ChangeNotifier>().forEach((c) => c.dispose()); | |
| formValid.dispose(); | |
| formError.dispose(); | |
| super.dispose(); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Centralised validation — all rules in one place | |
| // ------------------------------------------------------------------------- | |
| void _onFormChanged() { | |
| final name = username.text.trim(); | |
| if (name.isEmpty) { | |
| formError.value = 'Username is required'; | |
| formValid.value = false; | |
| return; | |
| } | |
| if (name.length < 3) { | |
| formError.value = 'Username must be at least 3 characters'; | |
| formValid.value = false; | |
| return; | |
| } | |
| if (tags.value.isEmpty) { | |
| formError.value = 'Pick at least one tag'; | |
| formValid.value = false; | |
| return; | |
| } | |
| if (!bio.isValid) { | |
| formError.value = bio.value.isEmpty | |
| ? 'Bio is required' | |
| : 'Bio is too long (${-bio.remaining} characters over limit)'; | |
| formValid.value = false; | |
| return; | |
| } | |
| if (!agreed.value) { | |
| formError.value = 'You must accept the terms'; | |
| formValid.value = false; | |
| return; | |
| } | |
| formError.value = null; | |
| formValid.value = true; | |
| } | |
| void _submit() { | |
| ScaffoldMessenger.of(context).showSnackBar( | |
| SnackBar( | |
| content: Text('Welcome, ${username.text.trim()}!'), | |
| behavior: SnackBarBehavior.floating, | |
| ), | |
| ); | |
| } | |
| // ------------------------------------------------------------------------- | |
| // Build | |
| // ------------------------------------------------------------------------- | |
| @override | |
| Widget build(BuildContext context) { | |
| final cs = Theme.of(context).colorScheme; | |
| final tt = Theme.of(context).textTheme; | |
| return Scaffold( | |
| appBar: AppBar(title: const Text('Create account')), | |
| body: ListView( | |
| padding: const EdgeInsets.all(24), | |
| children: [ | |
| // -- username -- | |
| TextField( | |
| controller: username, | |
| focusNode: usernameFocus, | |
| decoration: InputDecoration( | |
| labelText: 'Username', | |
| border: const OutlineInputBorder(), | |
| // Highlight the label when the field is focused — FocusNode is | |
| // included in the merge, so the whole form rebuilds on focus | |
| // change and we can use it here. | |
| labelStyle: TextStyle( | |
| color: usernameFocus.hasFocus ? cs.primary : null, | |
| ), | |
| ), | |
| ), | |
| const SizedBox(height: 20), | |
| // -- bio with live character counter -- | |
| Text('Bio', style: tt.bodyMedium), | |
| const SizedBox(height: 6), | |
| // ListenableBuilder reacts to bio changes and redraws the counter. | |
| ListenableBuilder( | |
| listenable: formController, | |
| builder: (context, _) { | |
| final tooLong = bio.remaining < 0; | |
| return Column( | |
| crossAxisAlignment: CrossAxisAlignment.end, | |
| children: [ | |
| TextField( | |
| onChanged: bio.update, | |
| maxLines: 3, | |
| decoration: InputDecoration( | |
| hintText: 'Tell us a bit about yourself', | |
| border: const OutlineInputBorder(), | |
| errorText: tooLong | |
| ? '${-bio.remaining} over limit' | |
| : null, | |
| ), | |
| ), | |
| const SizedBox(height: 4), | |
| Text( | |
| '${bio.remaining} characters left', | |
| style: tt.bodySmall?.copyWith( | |
| color: tooLong ? cs.error : cs.onSurfaceVariant, | |
| ), | |
| ), | |
| ], | |
| ); | |
| }, | |
| ), | |
| const SizedBox(height: 20), | |
| // -- tag chips -- | |
| Text('Interests', style: tt.bodyMedium), | |
| const SizedBox(height: 8), | |
| // ValueListenableBuilder rebuilds only this section on tag changes. | |
| ValueListenableBuilder<Set<String>>( | |
| valueListenable: tags, | |
| builder: (context, selected, _) { | |
| return Wrap( | |
| spacing: 8, | |
| children: _availableTags.map((tag) { | |
| return FilterChip( | |
| label: Text(tag), | |
| selected: selected.contains(tag), | |
| onSelected: (on) { | |
| final next = Set<String>.from(selected); | |
| on ? next.add(tag) : next.remove(tag); | |
| tags.value = next; | |
| }, | |
| ); | |
| }).toList(), | |
| ); | |
| }, | |
| ), | |
| const SizedBox(height: 20), | |
| // -- terms checkbox -- | |
| ValueListenableBuilder<bool>( | |
| valueListenable: agreed, | |
| builder: (context, value, _) { | |
| return CheckboxListTile( | |
| value: value, | |
| onChanged: (v) => agreed.value = v ?? false, | |
| title: const Text('I agree to the terms of service'), | |
| controlAffinity: ListTileControlAffinity.leading, | |
| contentPadding: EdgeInsets.zero, | |
| ); | |
| }, | |
| ), | |
| const SizedBox(height: 20), | |
| // -- validation error banner -- | |
| ValueListenableBuilder<String?>( | |
| valueListenable: formError, | |
| builder: (context, error, _) { | |
| if (error == null) return const SizedBox.shrink(); | |
| return Padding( | |
| padding: const EdgeInsets.only(bottom: 16), | |
| child: Text( | |
| error, | |
| style: tt.bodySmall?.copyWith(color: cs.error), | |
| ), | |
| ); | |
| }, | |
| ), | |
| // -- submit button — only this widget rebuilds on valid/invalid changes -- | |
| ValueListenableBuilder<bool>( | |
| valueListenable: formValid, | |
| builder: (context, isValid, _) { | |
| return FilledButton( | |
| onPressed: isValid ? _submit : null, | |
| child: const Text('Create account'), | |
| ); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment