Firestore CRUD with Flutter

Andy Julow

Dec 2, 2020

This article has been updated on December 2, 2020 to work with new syntax introduced with Firebase Core version 0.50

Google's Firebase is a natural choice for a serverless backend for Flutter and Firestore is a popular database within the platform. In this article we will explore basic CRUD operations with Firestore and Flutter.

To begin you will need to have or create a firebase project from the Firebase website

Create a new Flutter project in your terminal by typing flutter create firestore_crud. Follow the instructions for Android Setup and IOS Setup to link your Flutter project to Firebase

In your pubspec.yaml file, add dependencies for firebase_core, cloud_firestore, provider, and uuid. The first two are required for firestore operations, we will use Provider for dependency injection and state management, and UUID to generate unique ids for our database records.

/* pubspec.yaml */

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.2
firebase_core: ^0.5.2+1
cloud_firestore: ^0.14.3+1
provider: ^4.3.2+2
uuid: ^2.2.2
}

To keep your application organized it is important to have a good model, service, and state management system. Let's start with the model.

Inside the lib folder create a new folder called models and add a file called product.dart inside the models folder. Paste in the following code.

/* models/product.dart */

class Product{
final String productId;
final String name;
final double price;

Product({this.productId,this.price, this.name});

Map toMap(){
  return {
    'productId' : productId,
    'name' : name,
    'price' : price
  };
}

Product.fromFirestore(Map firestore)
    : productId = firestore['productId'],
      name = firestore['name'],
      price = firestore['price'];
}

The model builds the object properties and defines a constructor as all models do. In addition to these basic functions we have added a toMap and fromFirestore function. Firestore data will be received and passed as a Map while our application will work best with Dart objects defined by our model. To handle the flow of data in and out of maps we will use these two functions.

With a model in place, we can now turn to the service that will interact with Firestore. In the lib folder create a new directory called services and add a file called firestore_service.dart. Paste in the code below.

/* services/firestore_service.dart */

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firestore_crud/models/product.dart';

class FirestoreService {
FirebaseFirestore _db = FirebaseFirestore.instance;

Future saveProduct(Product product){
  return _db.collection('products').doc(product.productId).set(product.toMap());
}

Stream> getProducts(){
  return _db.collection('products').snapshots().map((snapshot) => snapshot.docs.map((document) => Product.fromFirestore(document.data())).toList());
}

Future removeProduct(String productId){
  return _db.collection('products').doc(productId).delete();
}

The service contains three calls to the Firestore database. We'll use the setData call to both insert and update our database records. We will tell Firestore which operation we want to execute by either including a new productId or an existing one. The toMap function is used from our model to turn the Dart object into a map for Firestore. The getProducts function sets up a stream of all of our products that can be displayed in a ListView. It is necessary to map the snapshot from the stream and then the document objects in the snapshot to obtain the map from Firestore. Once the map is obtained we can call the fromFirestore function from our model to cast it to a Dart object. Lastly we call toList() to produce a list of our Product objects. The removeProduct function is straightforward and will delete a product from our collection by the productId.

Now that we have a model and a service it is time to build in state management. We'll use Provider to serve up an instance of a class where we can store our temporary variables and also interact with the service. In the lib folder, add a new directory called providers and insert a file called product_provider.dart inside. Paste the code below.

/* providers/product_provider.dart */

import 'package:firestore_crud/models/product.dart';
import 'package:firestore_crud/services/firestore_service.dart';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';

class ProductProvider with ChangeNotifier {
final firestoreService = FirestoreService();
String _name;
double _price;
String _productId;
var uuid = Uuid();

//Getters
String get name => _name;
double get price => _price;

//Setters
changeName(String value) {
  _name = value;
  notifyListeners();
}

changePrice(String value) {
  _price = double.parse(value);
  notifyListeners();
}

loadValues(Product product){
  _name=product.name;
  _price=product.price;
  _productId=product.productId;
}


saveProduct() {
  print(_productId);
  if (_productId == null) {
    var newProduct = Product(name: name, price: price, productId: uuid.v4());
    firestoreService.saveProduct(newProduct);
  } else {
    //Update
    var updatedProduct =
        Product(name: name, price: _price, productId: _productId);
    firestoreService.saveProduct(updatedProduct);
  }
}

removeProduct(String productId){
  firestoreService.removeProduct(productId);
}

}

The ProductProvider class acts as the bridge between our UI and our service. The private variables in the class store variable state until we are ready to submit our values to the database. Getters and Setters allow for public access to the variables from the UI. Although we will not listen to changes in this project, the ChangeNotifier mixin and the notifyListeners() calls on each setter, when combined with Provider, allow the UI to listen to and respond to value changes. The loadValues function is there to keep our state consistent with our UI (more on this later), and the saveProduct and removeProduct functions allow the UI to call database changes without needing to import the service. In this class, saveProduct does the work of both an insert and an update by checking for the existance of a value in the private _productId variable. If no id exists, one is created with the UUID package and passed to the service.

With the backend complete we can now turn to the UI

Our UI will consist of two screens, a products page from which the user will be able to initiate a new record, and a detail page from which the user will be able to add, update, or delete a record.

Start by clearing out the main.dart file and replacing the contents with code below.

/* providers/main.dart */

import 'package:firestore_crud/providers/product_provider.dart';
import 'package:firestore_crud/screens/products.dart';
import 'package:firestore_crud/services/firestore_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
  final firestoreService = FirestoreService();

  return MultiProvider(
        providers: [
          ChangeNotifierProvider(create: (context) => ProductProvider()),
          StreamProvider(create: (context)=> firestoreService.getProducts()),
        ],
        child: MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Products(),
    ),
  );
}
}

The main file is responsible for setting up our providers which includes a single instance of our ProductProvider and our stream to fetch all products from Firestore.

Inside the lib folder add a screens directory and add edit_product.dart and products.dart inside. Products.dart will contain a listview based on the stream of products and an add button in the appbar. The code will look like this.

/* screens/products.dart */

import 'package:firestore_crud/screens/edit_product.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/product.dart';

class Products extends StatelessWidget {
@override
Widget build(BuildContext context) {
  final products = Provider.of>(context);

  return Scaffold(
      appBar: AppBar(
        title: Text('Products'),
        actions: [
          IconButton(
            icon: Icon(
              Icons.add,
              size: 30.0,
            ),
            onPressed: () {
              Navigator.of(context).push(
                  MaterialPageRoute(builder: (context) => EditProduct()));
            },
          )
        ],
      ),
      body: (products != null)
          ? ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(products[index].name),
                  trailing: Text(products[index].price.toString()),
                  onTap: () {
                    Navigator.of(context).push(MaterialPageRoute(
                        builder: (context) => EditProduct(products[index])));
                  },
                );
              })
          : Center(child: CircularProgressIndicator()));
}
}

The edit_product.dart file will also be a listview with textfields for input and a save and delete button. An optional product parameter is created at the top of the page using brackets in the constructor. If the page is updating an existing product is passed in here, otherwise the page is performing an add. We will use TextEditingControllers to set the initial data for the TextFields. TextEditingControllers must be disposed of to free up their resources and so a StatefulWidget is used to create the page. The code will look like this.

import 'package:firestore_crud/models/product.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';

class EditProduct extends StatefulWidget {
final Product product;

EditProduct([this.product]);

@override
_EditProductState createState() => _EditProductState();
}

class _EditProductState extends State {
final nameController = TextEditingController();
final priceController = TextEditingController();

@override
void dispose() {
  nameController.dispose();
  priceController.dispose();
  super.dispose();
}

@override
void initState() {
  if (widget.product == null) {
    //New Record
    nameController.text = "";
    priceController.text = "";
    new Future.delayed(Duration.zero, () {
      final productProvider = Provider.of(context,listen: false);
      productProvider.loadValues(Product());
    });
  } else {
    //Controller Update
    nameController.text=widget.product.name;
    priceController.text=widget.product.price.toString();
    //State Update
    new Future.delayed(Duration.zero, () {
      final productProvider = Provider.of(context,listen: false);
      productProvider.loadValues(widget.product);
    });
    
  }

  super.initState();
}

@override
Widget build(BuildContext context) {
  final productProvider = Provider.of(context);

  return Scaffold(
    appBar: AppBar(title: Text('Edit Product')),
    body: Padding(
      padding: const EdgeInsets.all(8.0),
      child: ListView(
        children: [
          TextField(
            controller: nameController,
            decoration: InputDecoration(hintText: 'Product Name'),
            onChanged: (value) {
              productProvider.changeName(value);
            },
          ),
          TextField(
            controller: priceController,
            decoration: InputDecoration(hintText: 'Product Price'),
            onChanged: (value) => productProvider.changePrice(value),
          ),
          SizedBox(
            height: 20.0,
          ),
          RaisedButton(
            child: Text('Save'),
            onPressed: () {
              productProvider.saveProduct();
              Navigator.of(context).pop();
            },
          ),
          (widget.product !=null) ? RaisedButton(
            color: Colors.red,
            textColor: Colors.white,
            child: Text('Delete'),
            onPressed: () {
              productProvider.removeProduct(widget.product.productId);
              Navigator.of(context).pop();
            },
          ): Container(),
        ],
      ),
    ),
  );
}
}

The TextFields have an onChanged method that will sync the provider state with the field contents, however when we set the intial values through initState the onChanged will not recognize this value. To set our state values equal to our TextFields on page load, we import our instance of the ProductProvider and call the loadValues function passing either an empty product, in the case of an add, or an existing product in the case of an edit. Since we lack a context object in the initState method, Future.delayed is used to bring in a context for Provider to find the instance of ProductProvider.

Although the form still lacks validation, and other production features basic CRUD operations with Firstore can now be performed.