Building a Flutter app, part 6: adding create, read, and update (“CRUD”) operations

Welcome to Part 6 of my Flutter app dev journal. Now that the Firebase connection is working, it’s time to add some “create”, “read”, and “update” functionality.

Full disclosure: I’m saving “delete” for later. This is just the “CRU” part of “CRUD”.

Seeding the database with the first bit of data

I decided to start with “shopping lists”, so the first collection I created in Firebase was shopping_lists and I gave it one document. Now there’s something to get from the database, and a collection to add to when new ones are made.

Note: I made an id field and copied the document’s ID into it so that the ID would be easily accessible on the record’s object.

Creating a database manager class

The DatabaseManager class is a singleton that’ll get imported into any file that needs to interact with the database. In my project, it is located in lib/db/database_manager.dart

This is the absolute simplest thing I could think of – all this does is get the shopping list records as a stream. I modeled it (loosely) on the examples found in this helpful tutorial.

database_manager.dart

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');

  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.snapshots();
  }
}

Using the database manager to get records from the Firebase database

I went back to main.dart and imported the DatabaseManager.

import './db/database_manager.dart';

Then, in the “return”, I added reference to a _shoppingLists widget:

return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                _shoppingLists(context),  <--- this is what's new

And then I created the new _shoppingLists widget, still in main.dart. It uses the StreamBuilder to get records (which may come in piecemeal from the database) and either display them as an ItemList or display an error.

  Widget _shoppingLists(BuildContext context) {
    return StreamBuilder(
        stream: db.getShoppingListStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.hasData && !snapshot.data.documents.isEmpty) {
            return ItemList(list: snapshot.data.documents, listType: 'shopping list', onItemTap: _goToList, onInfoTap: _editList);

          } else {
            return Text("Error: no shopping list data");
          }
        }
    );
  }

I had to update ItemList to create a ListItem of the correct type, like so:

return ListView.builder(
        shrinkWrap: true, // gives it a size
        primary: false, // so the shopping and store lists don't scroll independently
        itemCount: list.length + 1,
        itemBuilder: (BuildContext context, int index) {
          if (index == list.length) {
            if (listType == 'crossedOff') {
              return DeleteAll();
            } else { // store, shopping list
              return AddNew(list: list, listType: listType);
            }
          } else {
            var listItem;

            if (listType == 'shopping list') {
              listItem = ShoppingList(list[index]);
            } else if (listType == 'store') {
              //listItem = Store(list[index]);
            }

            return ListItem(item: listItem, listType: listType, count: getCount(listItem), onTap: onItemTap, onInfoTap: onInfoTap);
          }
        }
    );

And I had to update the ShoppingList model in shopping_list.dart to build itself from a document, like so:

import 'package:cloud_firestore/cloud_firestore.dart';

class ShoppingList {
  String id;
  String name;
  String listType = 'shopping list';
  List itemIDs;

  ShoppingList(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.itemIDs = document['itemIDs'];
  }
}

This is when my database permissions error became obvious, as no data was actually coming in from the db even though I was ready to display it in the Flutter app.

Troubleshooting Firebase access denied (“ConnectionState.waiting” always being true)

When I tried to get my data from the db, I got a “Missing or insufficient permissions” error.

I checked the value of snapshot.connectionState and found that it was equal to ConnectionState.waiting all the time.

  Widget _shoppingLists(BuildContext context) {
    return StreamBuilder(
        stream: db.getShoppingListStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.connectionState == ConnectionState.waiting) {
            return Text("waiting is true!");
          }
      ...

This StackOverflow post was helpful. This is where I discovered a newly created Firebase database does not allow access to anyone. It’s locked down by default.

The quick fix is to make read/write open to anyone.

By default, the rules are:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

I changed them to:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

This is obviously a bad idea in the long term, but I should be able to change the rules to allow registered, authorized users access to their own records (and deny everyone else) and I plan to build that soon, so for now this is acceptable.

Here’s where we’re at now:

“Hardware store” and “Groceries” are from the database, so this is forward progress even if we lost the “Stores” list in the process.

“Stores” broke because the ItemList widget expects to be fed a document snapshot, and I am going to fix that next.

This is mostly a repeat of the steps I just did to make shopping_lists. First, I added a stores collection and populated it with one store…

And then I added a way to retrieve the store record(s) in database_manager.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');
  final CollectionReference stores = Firestore.instance.collection('stores');
  
  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.snapshots();
  }

  Stream<QuerySnapshot> getStoresStream() {
    return stores.snapshots();
  }
}

And finally, create a _stores widget in main.dart that does the same thing _shoppingLists does:

Widget _stores(BuildContext context) {
    return StreamBuilder(
        stream: db.getStoresStream(),
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }

          if (snapshot.hasData && !snapshot.data.documents.isEmpty) {
            return ItemList(list: snapshot.data.documents, listType: 'store', onItemTap: _editStore, onInfoTap: _editStore);

          } else {
            return Text("Error: no store data");
          }
        }
    );

I also had to update the Store model in store.dart:

import 'package:cloud_firestore/cloud_firestore.dart';

class Store {
  String id;
  String name;
  String address;

  Store(DocumentSnapshot document) {
    this.id = document['id'];
    this.name = document['name'];
    this.address = document['address'];
  }
}

Now we have stores coming in from the database, too. Yay!

Adding new shopping lists and stores via the in-app forms

Creating the data manually in Firestore’s database dashboard is no fun, so it’s time to hook up the in-app forms to the real database.

I am going to begin by adding the “create a new shopping list” feature.

Data transfer object

For interactions with the database I like to use what’s called a “data transfer object”, it’s just a way of making sure the data sent to the db confirms to a certain structure. I created a new file, shopping_list_dto.dart and built it out as so:

class ShoppingListDTO {

  String name;
  String date;
  List itemIDs;

  String toString() {
    return 'name: $name, date: $date, storeIDs: $itemIDs';
  }

  Map<String, dynamic> toJson() => <String, dynamic> {
    'name': this.name,
    'date': this.date,
    'itemIDs': this.itemIDs,
  };
}

The “toJson” method will be useful when we need to format the data for insertion into the database.

Next, I modified new_shopping_list.dart:

import 'package:flutter/material.dart';
import 'package:grocery_go/db/database_manager.dart';
import 'package:grocery_go/db/shopping_list_dto.dart';

class NewShoppingList extends StatefulWidget {

  static const routeName = '/newShoppingList';

  NewShoppingList({Key key});

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

class _NewShoppingListState extends State<NewShoppingList> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Add new shopping list"),
      ),
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(20),
          child: AddShoppingListForm(),
        ),
      ),
    );
  }
}

class AddShoppingListForm extends StatefulWidget {
  @override
  _AddShoppingListFormState createState() => _AddShoppingListFormState();
}

class _AddShoppingListFormState extends State<AddShoppingListForm> {
  final formKey = GlobalKey<FormState>();

  final DatabaseManager db = DatabaseManager();

  final newShoppingListFields = ShoppingListDTO();

  String validateStringInput(String value) {
    if (value.isEmpty) {
      return 'Please enter a name';
    } else return null;
  }

  void saveNewList(BuildContext context) {
    final formState = formKey.currentState;
    if (formState.validate()) {
      // save the form
      formKey.currentState.save();
      // this data is auto-generated when a new list is made
      newShoppingListFields.date = DateTime.now().toString();
      newShoppingListFields.itemIDs = new List<String>();
      // put this stuff in the db
      db.addShoppingList(newShoppingListFields);
      // confirm with a snack bar
      Scaffold.of(context).showSnackBar(
          SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
      );
      // go back to main view
      Navigator.of(context).pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formKey,
      child: Column(
        children: [
          TextFormField(
              autofocus: true,
              decoration: InputDecoration(
                  labelText: 'Shopping list name',
                  border: OutlineInputBorder()
              ),
              validator: (value) => validateStringInput(value),
              onSaved: (value) {
                newShoppingListFields.name = value;
              }
          ),
          RaisedButton(
            onPressed: () => saveNewList(context),
            child: Text('Save'),
          ),
        ],
      ),
    );
  }
}

What’s new:

  • Changed the “form” into an actual Form widget so it can behave like a proper Flutter form
  • Instantiates an instance of ShoppingListDTO called newShoppingListFields and fills it out using form data
  • Added a form key (which is how Flutter distinguishes forms from each other)
  • Added validator property to TextFormField and created a simple validation method (all it does is check that there’s any input at all)
  • Added onSaved property to TextFormField so it knows what to do when the form is saved
  • Added saveNewList method that runs validation and, if valid, sends this form data off to the db and returns the user to the main screen
  • Changed the way the date is generated, it’s now converted to a string before it goes to the db

Getting this all working took a bit of trial and error. I experimented with Timestamp and DateTime objects before settling on pushing the date to the db as a string. I also had to try a few things before I figured out how to push the empty itemIDs array into the db in a way that would be recognized (on retrieval) as having a length.

Here it is! Now the user can create a new list and see it on the main screen.

And here it is in the database:

Just one thing is missing: the form-created shopping list needs to have its “id” field filled in after the record is created, so that’s next up.

Grabbing the new record’s ID and saving it to the record

I’d like every document (so every item, every shopping list, every store, etc.) to store its own ID in a data field. This should be useful for editing entries later on.

I wasn’t completely sure how to approach this at first – there is no ID until the document is created, so I have to created something in order to get that ID back.

Fortunately, Firebase has some documentation covering this use case. When you call the .add method, a DocumentReference is returned.

I’m already using .add, like so:

  Future<DocumentReference> addShoppingList(ShoppingListDTO shoppingList) {
    return shoppingLists.add(shoppingList.toJson());
  }

But I don’t have an update function yet, so I created that next:

  Future<void> updateShoppingList(String id, ShoppingListDTO shoppingList) {
    return shoppingLists.document(id).updateData(shoppingList.toJson());
  }

This lets me grab ID that comes back and immediately update the document to have that ID, like so (in new_shopping_list.dart):

void saveNewList(BuildContext context) async {
  final formState = formKey.currentState;
  if (formState.validate()) {
    // save the form
    formKey.currentState.save();

    // this data is auto-generated when a new list is made
    newShoppingListFields.date = DateTime.now().toString();
    newShoppingListFields.itemIDs = new List<String>();

    // put this stuff in the db and get the ID that was created
    DocumentReference docRef = await db.addShoppingList(newShoppingListFields);

    // update the record to have the ID that was generated
    newShoppingListFields.id = docRef.documentID;
    db.updateShoppingList(docRef.documentID, newShoppingListFields);

    // confirm it with a snack bar
    Scaffold.of(context).showSnackBar(
        SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
    );

    // go back to main view
    Navigator.of(context).pop();
  }
}

Now the document’s ID is duplicated into a field on that document:

Refactoring it a bit…

This works, but I don’t like that my DatabaseManager is basically foisting this work onto whatever code is calling it. We’re never going to create a document and then not immediately turn around and slap the ID into it, so I wanted to see if I could put encapsulate this work within database_manager.dart

Initially, I ran into trouble trying to create a DocumentReference – a Future<DocumentReference> is not a DocumentReference, it seems.

This might be a job for async/await, which I’ve used in JavaScript/TypeScript but have not yet attempted in Flutter/Dart, so here we go – now it’s async/awaited and returning that DocumentReference.

  Future<DocumentReference> addShoppingList(ShoppingListDTO shoppingList) async {
    DocumentReference docRef = await shoppingLists.add(shoppingList.toJson());
    shoppingLists.document(docRef.documentID).updateData({'id':docRef.documentID});
    return docRef;
  }

And then over here in new_shopping_list.dart, I removed the “update the ID” code:

void saveNewList(BuildContext context) async {
  final formState = formKey.currentState;
  if (formState.validate()) {
    // save the form
    formKey.currentState.save();

    // this data is auto-generated when a new list is made
    newShoppingListFields.date = DateTime.now().toString();
    newShoppingListFields.itemIDs = new List<String>();

    // put this stuff in the db
    var docRef = await db.addShoppingList(newShoppingListFields);
    print("Created record: " + docRef.documentID);

    // confirm it with a snack bar
    Scaffold.of(context).showSnackBar(
        SnackBar(content: Text('New list created: ' + newShoppingListFields.name))
    );

    // go back to main view
    Navigator.of(context).pop();
  }
}

(I later cleaned it up by removing var docRef = and the print statement, I just wanted those to confirm that everything was working the way I expected.)

And there we have it – now the work of updating the ID is done by the DatabaseManager, which I think is just a better design for this particular use case.

All of the code pertaining to hooking up to Firebase and getting creation and retrieval working can be found in this pull request. (Hey, we’re halfway to a CRUD app!)

Sorting the shopping lists by name (alphabetically) as they come in from Firebase

Before we move on and Firebase-ify the rest of the app, I want to fix the way shopping lists appear in a seemingly random (or at least unpredictable) order.

In the long run, it’d be nice if the user could re-order these lists, but for now, I think I’ll sort them alphabetically.

Firebase has .orderBy, but it took me a bit of trial and error to realize I had to apply it to the collectionReference, not the part where we get that reference in the first place (so not the Firestore.instance.collection("collectionName") part.

Like this:

class DatabaseManager {

  final CollectionReference shoppingLists = Firestore.instance.collection('shopping_lists');
  final CollectionReference stores = Firestore.instance.collection('stores');

  Stream<QuerySnapshot> getShoppingListStream() {
    return shoppingLists.orderBy("name").snapshots();
  }

  Stream<QuerySnapshot> getStoresStream() {
    return stores.orderBy("name").snapshots();
  }

  ...

Which results in the shopping lists being sorted by name:

Minor thing, but it was bothering me the way new ones didn’t seem to have any rhyme or reason to where they ended up in the list.

Hooking up the rest of the app to the database

Making the rest of the app work with Firebase was a decent amount of work, and most of it was a re-hash of what was already done above, but I still broke the major steps into individual pull requests for anyone interested in seeing them.

Subcollections

Working with Items (items being things like “eggs”, “bread”, etc.) made it apparent that they should be stored as a subcollection of a Shopping List, rather than have their IDs saved in an array on shopping list and retrieved separately.

Firebase seems to prefer you just stick the child document(s) inside their parent documents, rather than maintain a list of child document IDs the way I’m used to doing with MySQL databases.

In Firebase, the shopping list’s ‘items’ subcollection looks like this:

To create an item and add it to the subcollection, database_manager.dart now has the following method:

  Future<DocumentReference> createItem(String parentListID, ItemDTO item) async {
    // 1
    shoppingLists.document(parentListID).updateData({'itemCount': FieldValue.increment(1)});
    // 2
    DocumentReference itemDocRef = await shoppingLists.document(parentListID).collection('items').add(item.toJson());
    // 3
    itemDocRef.updateData({'id':itemDocRef.documentID});
    return itemDocRef;
  }
  1. Gets the parent shopping list document by its ID and updates “itemCount” (since we don’t have the itemIDs array to get the length of anymore)
  2. Gets the parent shopping list document by its ID, gets the collection of ‘items’ within, and adds the new Item (as JSON) to that subcollection of items
  3. Immediately gives that new Item its own ID as a field called ‘id’

The app now uses real data from the Firebase db for its shopping lists, items, and stores!

At this point the app has the most basic “create”, “read”, and “update” support for shopping lists, stores, and items, but the app needs a whole bunch of feature love to feel more polished.

Join me in Part 7 [Coming soon] as I add a bunch of new features.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.