Skip to main content

Forms and validation

About 5 min

Forms and validation

In the previous chapters we added Firebase Authentication and a Firestore ListView for Travel Notes. The app already works, but the input screens still use plain TextField widgets.

In this chapter we will improve those screens with Form and TextFormField:

  • validate the login and register fields before calling Firebase Authentication
  • validate trip data before saving it to Firestore
  • prevent double submits while an async request is running
  • show clear error messages

TextField vs TextFormField

WidgetUse when
TextFieldyou need simple input without validation
TextFormFieldthe input belongs to a form and must be validated
Formyou want to validate multiple fields together

The Form widget needs a GlobalKey<FormState>. With this key we can call validate() when the user presses a button.

Validate the login page

Replace login.dart in the pages folder with the code below.

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

import '../services/auth_service.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final AuthService authService = AuthService();
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  final TextEditingController emailController = TextEditingController();
  final TextEditingController passwordController = TextEditingController();

  bool isRegistering = false;
  bool isLoading = false;
  String? errorMessage;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(isRegistering ? "Create account" : "Login"),
      ),
      body: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Form(
            key: formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextFormField(
                  controller: emailController,
                  keyboardType: TextInputType.emailAddress,
                  decoration: const InputDecoration(
                    labelText: "Email",
                    border: OutlineInputBorder(),
                  ),
                  validator: (value) {
                    final email = value?.trim() ?? '';

                    if (email.isEmpty) {
                      return "Email is required";
                    }

                    if (!email.contains("@")) {
                      return "Enter a valid email address";
                    }

                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: passwordController,
                  obscureText: true,
                  decoration: const InputDecoration(
                    labelText: "Password",
                    border: OutlineInputBorder(),
                  ),
                  validator: (value) {
                    if (value == null || value.length < 6) {
                      return "Use at least 6 characters";
                    }

                    return null;
                  },
                ),
                const SizedBox(height: 12),
                if (errorMessage != null)
                  Text(
                    errorMessage!,
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.error,
                    ),
                  ),
                const SizedBox(height: 12),
                SizedBox(
                  width: double.infinity,
                  child: ElevatedButton(
                    onPressed: isLoading ? null : _submit,
                    child: isLoading
                        ? const CircularProgressIndicator()
                        : Text(isRegistering ? "Create account" : "Login"),
                  ),
                ),
                TextButton(
                  onPressed: isLoading ? null : _toggleMode,
                  child: Text(
                    isRegistering
                        ? "Already have an account? Login"
                        : "No account yet? Create one",
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  void _toggleMode() {
    setState(() {
      isRegistering = !isRegistering;
      errorMessage = null;
    });
  }

  Future<void> _submit() async {
    if (!formKey.currentState!.validate()) {
      return;
    }

    setState(() {
      isLoading = true;
      errorMessage = null;
    });

    try {
      if (isRegistering) {
        await authService.register(
          email: emailController.text.trim(),
          password: passwordController.text,
        );
      } else {
        await authService.signIn(
          email: emailController.text.trim(),
          password: passwordController.text,
        );
      }
    } on FirebaseAuthException catch (error) {
      setState(() {
        errorMessage = error.message;
      });
    } finally {
      if (mounted) {
        setState(() {
          isLoading = false;
        });
      }
    }
  }

  
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }
}
codedescription
GlobalKey<FormState>gives access to the form state
Form(key: formKey, ...)groups the email and password fields
TextFormFieldtext input with a validator
formKey.currentState!.validate()runs all validators in the form
SingleChildScrollViewkeeps the form usable when the keyboard takes space
isLoading ? null : _submitdisables the button while Firebase is busy

Compared to the authentication chapter, the page still uses the same AuthService and the same login/register flow. The difference is that the input fields are now part of a Form. When the user presses the button, _submit() first calls validate(). Firebase is only called when all validators return null.

The email and password values are still read from the same controllers. The email is trimmed before it is sent to Firebase, so accidental spaces before or after the address do not break login. The password is not trimmed, because spaces can be part of a password.

The button is disabled while isLoading is true. This prevents the user from sending the same login or register request multiple times. Firebase errors are still caught with FirebaseAuthException, but the message is now shown inside the validated form.

Validate the trip form

The Firestore ListView chapter created trip_detail.dart with normal TextField widgets. That page already saves data through TripRepository, so we only need to improve the input flow.

Replace trip_detail.dart in the pages folder with the code below.

import 'package:flutter/material.dart';

import '../models/trip.dart';
import '../repositories/trip_repository.dart';

class TripDetailPage extends StatefulWidget {
  final String? id;

  const TripDetailPage({super.key, this.id});

  
  State<TripDetailPage> createState() => _TripDetailPageState();
}

class _TripDetailPageState extends State<TripDetailPage> {
  final TripRepository repository = TripRepository();
  final GlobalKey<FormState> formKey = GlobalKey<FormState>();
  final TextEditingController titleController = TextEditingController();
  final TextEditingController destinationController = TextEditingController();
  final TextEditingController descriptionController = TextEditingController();
  final TextEditingController tagsController = TextEditingController();
  final TextEditingController checklistController = TextEditingController();

  Trip? trip;
  bool isLoading = false;
  bool isSaving = false;

  bool get isNewTrip => widget.id == null;

  
  void initState() {
    super.initState();

    if (!isNewTrip) {
      _loadTrip();
    }
  }

  Future<void> _loadTrip() async {
    setState(() {
      isLoading = true;
    });

    trip = await repository.fetchTrip(widget.id!);

    titleController.text = trip?.title ?? '';
    destinationController.text = trip?.destination ?? '';
    descriptionController.text = trip?.description ?? '';
    tagsController.text = trip?.tags.join(', ') ?? '';
    checklistController.text = trip?.checklist.join('\n') ?? '';

    setState(() {
      isLoading = false;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(isNewTrip ? "New trip" : "Trip details"),
        actions: [
          if (!isNewTrip)
            IconButton(
              onPressed: isSaving ? null : _deleteTrip,
              icon: const Icon(Icons.delete),
            ),
          IconButton(
            onPressed: isSaving ? null : _saveTrip,
            icon: const Icon(Icons.save),
          ),
        ],
      ),
      body: isLoading
          ? const Center(child: CircularProgressIndicator())
          : Form(
              key: formKey,
              child: ListView(
                padding: const EdgeInsets.all(12),
                children: [
                  TextFormField(
                    controller: titleController,
                    textInputAction: TextInputAction.next,
                    decoration: const InputDecoration(
                      labelText: "Title",
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return "Title is required";
                      }

                      if (value.trim().length < 3) {
                        return "Use at least 3 characters";
                      }

                      return null;
                    },
                  ),
                  const SizedBox(height: 12),
                  TextFormField(
                    controller: destinationController,
                    textInputAction: TextInputAction.next,
                    decoration: const InputDecoration(
                      labelText: "Destination",
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value == null || value.trim().isEmpty) {
                        return "Destination is required";
                      }

                      return null;
                    },
                  ),
                  const SizedBox(height: 12),
                  TextFormField(
                    controller: descriptionController,
                    minLines: 3,
                    maxLines: 5,
                    decoration: const InputDecoration(
                      labelText: "Description",
                      border: OutlineInputBorder(),
                    ),
                    validator: (value) {
                      if (value != null && value.length > 500) {
                        return "Use at most 500 characters";
                      }

                      return null;
                    },
                  ),
                  const SizedBox(height: 12),
                  TextFormField(
                    controller: tagsController,
                    decoration: const InputDecoration(
                      labelText: "Tags",
                      helperText: "Separate tags with commas",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 12),
                  TextFormField(
                    controller: checklistController,
                    minLines: 3,
                    maxLines: 6,
                    decoration: const InputDecoration(
                      labelText: "Checklist",
                      helperText: "Put each item on a new line",
                      border: OutlineInputBorder(),
                    ),
                  ),
                  const SizedBox(height: 16),
                  FilledButton.icon(
                    onPressed: isSaving ? null : _saveTrip,
                    icon: isSaving
                        ? const SizedBox(
                            width: 18,
                            height: 18,
                            child: CircularProgressIndicator(strokeWidth: 2),
                          )
                        : const Icon(Icons.save),
                    label: Text(isSaving ? "Saving..." : "Save trip"),
                  ),
                ],
              ),
            ),
    );
  }

  Future<void> _saveTrip() async {
    if (!formKey.currentState!.validate()) {
      return;
    }

    setState(() {
      isSaving = true;
    });

    final editedTrip = Trip(
      id: widget.id ?? '',
      title: titleController.text.trim(),
      destination: destinationController.text.trim(),
      description: descriptionController.text.trim(),
      tags: _parseCommaSeparated(tagsController.text),
      checklist: _parseLines(checklistController.text),
    );

    try {
      if (isNewTrip) {
        await repository.createTrip(editedTrip);
      } else {
        await repository.updateTrip(editedTrip);
      }

      if (mounted) {
        Navigator.pop(context);
      }
    } catch (_) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text("Could not save trip")),
        );
      }
    } finally {
      if (mounted) {
        setState(() {
          isSaving = false;
        });
      }
    }
  }

  Future<void> _deleteTrip() async {
    setState(() {
      isSaving = true;
    });

    await repository.deleteTrip(widget.id!);

    if (mounted) {
      Navigator.pop(context);
    }
  }

  List<String> _parseCommaSeparated(String value) {
    return value
        .split(',')
        .map((item) => item.trim())
        .where((item) => item.isNotEmpty)
        .toList();
  }

  List<String> _parseLines(String value) {
    return value
        .split('\n')
        .map((item) => item.trim())
        .where((item) => item.isNotEmpty)
        .toList();
  }

  
  void dispose() {
    titleController.dispose();
    destinationController.dispose();
    descriptionController.dispose();
    tagsController.dispose();
    checklistController.dispose();
    super.dispose();
  }
}
codedescription
Formvalidates the complete trip screen
TextFormFieldreplaces the earlier TextField widgets
titleController.text.trim()stores clean text in Firestore
isSavingprevents pressing save or delete multiple times
try/catchshows feedback if Firestore cannot save
_parseCommaSeparatedconverts city, food, culture to a list
_parseLinesconverts multiple checklist lines to a list

The trip is still saved through TripRepository. Because the previous chapter uses a Firestore stream in TripListPage, the list updates automatically when the user returns from this form.

Compared to the Firestore ListView chapter, the page still opens in the same way. TripListPage still navigates to TripDetailPage, and TripDetailPage still calls createTrip, updateTrip and deleteTrip on TripRepository. The data structure in Firestore does not change.

The important change is the form layer around the existing fields. TextField is replaced by TextFormField, and the required fields now have validators. The title must be filled in and must contain at least three characters. The destination must be filled in. The description is optional, but it is limited to 500 characters.

Saving also becomes safer. _saveTrip() first validates the form, then trims the text that will be stored in Firestore. While Firestore is saving, isSaving disables the save and delete buttons. If saving fails, the page stays open and shows a SnackBar instead of silently failing.

Validation best practices

  • Validate as close as possible to the input field.
  • Use clear error messages that tell the user how to fix the problem.
  • Disable buttons while a Firebase request is running.
  • Trim input before storing it when spaces are not meaningful.
  • Use the correct keyboardType, such as TextInputType.emailAddress.
  • Never trust client-side validation only. Firestore security rules are still needed.

Exercise

Extend the trip form with one extra field:

  • Add a budgetController.
  • Add a TextFormField with keyboardType: TextInputType.number.
  • Validate that the budget is empty or a positive number.
  • Store the budget in the trip document.