Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Created March 24, 2026 08:55
Show Gist options
  • Select an option

  • Save PlugFox/eed8aaa9fe83a032f17139e8b1814986 to your computer and use it in GitHub Desktop.

Select an option

Save PlugFox/eed8aaa9fe83a032f17139e8b1814986 to your computer and use it in GitHub Desktop.
Form State Management
/*
* 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