Cloud Firestore is Google’s hosted document database available either as part of a Firebase or via Google Cloud. The docs for Flutter’s Firestore adapter recently moved FlutterFire over to Google Cloud’s main docs site

Type safety

Cloud Firestore does not use a schema. Its datatypes are similar to JSON’s with a few additions, such as Timestamp

There is a package called Cloud Firestore ODM that lets you work with strictly typed model objects in your dart code instead of Map<String, dynamic> which is the default.

The syntax isn’t all that bad, but it feels like you’re typing everything twice. Not that that’s an uncommon pattern in Flutter: a stateless widget that takes a lot of inputs looks very similar. But perhaps we can do better:

Movie defined with ODM

import 'package:cloud_firestore_odm/cloud_firestore_odm.dart';
import 'package:json_annotation/json_annotation.dart';

part 'movie.g.dart';

@JsonSerializable()
class Movie {
  Movie({
    this.genre,
    required this.likes,
    required this.poster,
    required this.rated,
    required this.runtime,
    required this.title,
    required this.year,
    required this.id,
  }) {
    _$assertMovie(this);
  }

  @Id()
  final String id;
  final String poster;
  @Min(0)
  final int likes;
  final String title;
  @Min(0)
  final int year;
  final String runtime;
  final String rated;
  final List<String>? genre;
}

There’s a more popular package that does something similar, called freezed. Let’s see what the corresponding code would look like with Freezed:

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

part 'movie.freezed.dart';
part 'movie.g.dart';

@freezed
class Movie with _$Movie {
  const factory Movie({
    required String id,
    required String poster,
    required int likes,
    required String title,
    required int year,
    required String runtime,
    required String rated,
    List<String>? genre;
  }) = _Movie;

  factory Movie.fromJson(Map<String, Object?> json)
      => _$MovieFromJson(json);
}

That’s more concise for sure! Freezed is clever enough to let us put both the type and the name in the constructor and derive everything else from there at generation time. 🥳

In both cases, we have to generate the .g.dart files, by running the command dart run build_runner build each time we’ve changed the model source code.

With Freezed, we also get immutability and tools like copyWith() for free which are great when dealing with StateProviderNotifier in Riverpod.

Or we can use the @unfreezed annotation instead if we prefer mutable objects.

This thing could benefit from a couple of additional bits to play nicely with Firestore though - here are two helpers that act as a bridge to the interfaces of the cloud_firestore package:

  factory Movie.fromFirstore(
          DocumentSnapshot snapshot, SnapshotOptions? options) =>
      Movie.fromJson(snapshot.data() as Map<String, dynamic>);

  static Map<String, Object?> toFirestore(Movie Movie, SetOptions? options) =>
      Movie.toJson();

Now we can inject withConverter() into our queries like so:

// Get a single movie doc
final movieSnapshot = await FirebaseFirestore.instance
        .collection('movies')
        .doc(movieId)
        .withConverter<Movie>(
            fromFirestore: Movie.fromFirstore, toFirestore: Movie.toFirestore)
        .get();

// Listen to updates for a single movie doc
final movieStream = FirebaseFirestore.instance
        .collection('movies')
        .doc(movieId)
        .withConverter<Movie>(
            fromFirestore: Movie.fromFirstore, toFirestore: Movie.toFirestore)
        .snapshots();

// Listen to all movie docs
final moviesStream = FirebaseFirestore.instance
        .collection('movies')
        .withConverter<Movie>(
            fromFirestore: Movie.fromFirstore, toFirestore: Movie.toFirestore)
        .snapshots();

…and get back our freezed model instances, i.e. List<Movie>, instead of List<Map<String, dynamic>>! 🚀