Goals

In this codelab, you'll build a multi-platform restaurant recommendation app powered by Flutter and Cloud Firestore.

The finished app runs on Android, iOS, and web, from a single Dart codebase.

What you'll learn

What would you like to learn from this codelab?

I'm new to the topic, and I want a good overview. I know something about this topic, but I want a refresher. I'm looking for example code to use in my project. I'm looking for an explanation of something specific.

What you need

If you're not very familiar with Flutter or Firestore, first complete the Firebase for Flutter codelab:

To complete this codelab, you need:

Create a Firebase project

  1. In the Firebase console, click Add project, and then name the Firebase project FriendlyEats.
    Remember the project ID for your Firebase project (or click the Edit icon to set your preferred project ID).
  2. Click Create project.

The application that you build uses several Firebase services available on the web:

Next, you walk through configuring and enabling the services using the Firebase console.

Enable Anonymous Auth

Although authentication isn't the focus of this codelab, it's important to have some form of authentication in your app. You'll use Anonymous login—meaning that the user is silently signed in without being prompted.

To enable Anonymous login:

  1. In the Firebase console, locate the Develop section in the left navigation bar.
  2. Click Authentication, and then click the Sign-in method tab (or go directly to the Firebase console).
  3. Enable the Anonymous Sign-in Provider, and click Save.

Enabling Anonymous login allows the application to silently sign in your users when they access the web app. To learn more, see the anonymous authentication documentation.

Enable Cloud Firestore

The app uses Cloud Firestore to save and receive restaurant information and ratings.

To enable Cloud Firestore:

  1. In the Firebase console's Develop section, click Database.
  2. Click Create database in the Cloud Firestore pane.

  1. Select the Start in test mode option, and click Enable after reading the disclaimer about the security rules.

Test mode ensures that you can freely write to the database during development. You make your database more secure later in this codelab.

Clone the GitHub repository from the command line:

git clone https://github.com/FirebaseExtended/codelab-friendlyeats-flutter.git friendlyeats-flutter

The sample code should be cloned into the 📁friendlyeats-flutter directory. From now on, make sure you run commands from this directory:

cd friendlyeats-flutter

Import the starter app

Open or import the 📁friendlyeats-flutter directory into your preferred IDE. This directory contains the starting code for the codelab which consists of a not-yet-functional restaurant recommendation app.

You make it functional throughout this codelab, so you edit code in that directory soon.

Locate the files to work on

Even though the usual entry point for a Flutter app is its lib/main.dart file, in this codelab, you focus on the data side of things.

Locate the following files in the project:

Firebase CLI

The Firebase command-line interface (CLI) allows you to deploy your web app and configuration to Firebase directly from files in your project.

  1. Install the CLI by running the following npm command:
npm -g install firebase-tools
  1. Verify that the CLI was installed correctly by running the following command:
firebase --version

Make sure that the version of the Firebase CLI is v7.4.0 or later.

  1. Authorize the Firebase CLI by running the following command:
firebase login

The repository you cloned in the prior step already has a firebase.json file with some ready-made project configuration (location of other configuration files, hosting deployment, and so on). Now you need to associate your working copy of the app with your Firebase project:

  1. Make sure that your command line is accessing your app's local directory.
  1. Associate your app with your Firebase project by running the following command:
firebase use --add
  1. When prompted, select your Project ID, and give your Firebase project an alias.

An alias is useful if you have multiple environments (production, staging, and so on). However, for this codelab, just use the alias of default.

  1. Follow the instructions provided in your command line.

Enable web support for Flutter

To compile your Flutter app to run on the web, you must enable this feature (which is currently in beta). To enable web support, enter the following:

$ flutter channel beta
$ flutter upgrade
$ flutter config --enable-web

In your IDE under the devices pulldown, or at the command line using flutter devices, you should now see Chrome and Web server listed.

The Chrome device automatically starts Chrome. The Web server starts a server that hosts the app so that you can load it from any browser.

Use the Chrome device during development so that you can use DevTools, and use the Web server when you want to test on other browsers.

After you create a Firebase project, you can configure one (or more) apps to use that Firebase project. You do the following:

If you're developing your Flutter app for multiple platforms, then you need to register each platform your app runs on within the same Firebase project.

This codelab focuses on the web platform, because iOS and Android are covered in the Firebase for Flutter codelab. Go to that codelab if you want to add support for Android or iOS to your FriendlyEats app.

In your Flutter app, there's a special web/index.html file that is used as the entry point to your app when running on the web. You modify that entry point with a specific configuration for your project, so your web application can connect to the Firebase backend.

Configure for web

  1. In the Firebase console, select Project Overview in the left navigation bar, and click the Web button under Get started by adding Firebase to your app. You should see the following dialog:

  1. Give your app a nickname. This is the value that is used across the Firestore console to identify the web version of your app.
  2. Click Register app.
  3. After you register your app, the Add Firebase SDK step gives you some code you need to paste into the web/index.html file of your Flutter app. When complete, it should look similar to this:

web/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>friendlyeats</title>

  <!-- The core Firebase JS SDK is always required and must be listed first -->
  <script src="https://www.gstatic.com/firebasejs/7.5.1/firebase-app.js"></script>

  <!-- TODO: Add SDKs for Firebase products that you want to use
      https://firebase.google.com/docs/web/setup#available-libraries -->

  <script>
    // Your web app's Firebase configuration
    var firebaseConfig = {
      apiKey: "YoUr_RaNdOm_API_kEy",
      authDomain: "your-project-name.firebaseapp.com",
      databaseURL: "https://your-project-name.firebaseio.com",
      projectId: "your-project-name",
      storageBucket: "your-project-name.appspot.com",
      messagingSenderId: "012345678901",
      appId: "1:109876543210:web:r4nd0mH3xH45h"
    };
    // Initialize Firebase
    firebase.initializeApp(firebaseConfig);
  </script>

</head>
<body>
  <script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
  1. Notice that there's a TODO in the code that you just pasted. You fix that now. Because the codelab uses Firebase Auth and Firestore, add the script tags for those products now:

web/index.html

  ...
  <!-- TODO: Add SDKs for Firebase products that you want to use
      https://firebase.google.com/docs/web/setup#available-libraries -->

  <script src="https://www.gstatic.com/firebasejs/7.5.1/firebase-auth.js"></script>
  <script src="https://www.gstatic.com/firebasejs/7.5.1/firebase-firestore.js"></script>

  <script>
    // Your web app's Firebase configuration
    var firebaseConfig = {
      ...
  1. Save your web/index.html file, and click Continue to console in the Add Firebase to your web app dialog.
  2. Your Flutter app is ready to connect to Firebase!


You've found something special!

Most of the code changes required to enable Firebase support are already checked into the project that you're working on. However, in order to add support for mobile platforms, you need to follow a process similar to what you just did for web:

In the top-level directory of your Flutter app, there are subdirectories called ios and android. These directories hold the platform-specific configuration files for iOS and Android, respectively.

Configure iOS

  1. In the Firebase console, select Project Overview in the left navigation bar, and click the iOS button under Get started by adding Firebase to your app.

You should see the following dialog:

  1. The important value to provide is the iOS bundle ID. You get the bundle ID by performing the next three steps.
  1. In the command-line tool, go to the top-level directory of your Flutter app.
  2. Run the command open ios/Runner.xcworkspace to open Xcode.
  1. In Xcode, click the top-level Runner in the left pane, to show the General tab in the right pane, as shown. Copy the Bundle Identifier value.

  1. Go back to the Firebase dialog, paste the copied Bundle Identifier into the iOS bundle ID field, and click Register App.
  1. Continuing in Firebase, follow the instructions to download the configuration file GoogleService-Info.plist.
  2. Go back to Xcode. Notice that Runner has a subfolder also called Runner (shown in the preceding image).
  3. Drag the GoogleService-Info.plist file (that you just downloaded) into that Runner subfolder.
  4. In the dialog that appears in Xcode, click Finish.
  5. Go back to the Firebase console. In the setup step, click Next, skip the remaining steps, and go back to the main page of the Firebase console.


You're done configuring your Flutter app for iOS!

Configure Android

  1. In the Firebase Console, select Project Overview in the left navigation bar, and click the Android button under Get started by adding Firebase to your app.

You'll should see the following dialog :

  1. The important value to provide is the Android package name. You get the package name when you perform the following two steps:
  1. In your Flutter app directory, open the file android/app/src/main/AndroidManifest.xml.
  2. In the manifest element, find the string value of the package attribute. This value is the Android package name (something like com.yourcompany.yourproject). Copy this value.
  3. In the Firebase dialog, paste the copied package name into the Android package name field.
  4. You don't need the Debug signing certificate SHA-1 for this codelab. Leave this blank.
  5. Click Register App.
  6. Continuing in Firebase, follow the instructions to download the configuration file google-services.json.
  7. Go to your Flutter app directory, and move the google-services.json file (that you just downloaded) into the android/app directory.
  8. Back in the Firebase console, skip the remaining steps, and go back to the main page of the Firebase console.
  9. All the Gradle configuration is already checked in. If your app is still running, then close and rebuild it, to allow gradle to install dependencies.

You're done configuring your Flutter app for Android!

You're ready to actually start work on your app! First, run the app locally. You can now run the app in any platform that you configured (and for which you have a device and emulator available).

Discover which devices are available with the following command:

flutter devices

Depending on which devices are available, the output of the preceding command looks something like this:

3 connected devices:

Android SDK built for x86 • emulator-5554 • android-x86    • Android 7.1.1 (API 25) (emulator)
Chrome                    • chrome        • web-javascript • Google Chrome 79.0.3945.130
Web Server                • web-server    • web-javascript • Flutter Tools

We'll continue this codelab using the chrome device.

  1. Run the following Flutter CLI command:
flutter run -d chrome
  1. Flutter begins with Building your application for the web..., and automatically opens a Chrome window with the running app.

Now, you should see your copy of FriendlyEats, connected to your Firebase project.

The app automatically connects to your Firebase project and silently signs you in as an anonymous user.

In this section, you write some data to Cloud Firestore to populate the app's UI. This can be done manually using the Firebase console, but you'll do it in the app, to see a demonstration of a basic Cloud Firestore write.

Data Model

Firestore data is split into collections, documents, fields, and subcollections. Each restaurant is stored as a document in a top-level collection called restaurants.

Later, you store each review in a subcollection called ratings inside each restaurant.

Add restaurants to Firestore

The main model object in the app is a restaurant. Next, write some code that adds a restaurant document to the restaurants collection.

  1. Open lib/src/model/data.dart.
  2. Find the function addRestaurant.
  3. Replace the entire function with the following code.

lib/src/model/data.dart

Future<void> addRestaurant(Restaurant restaurant) {
  final restaurants = Firestore.instance.collection('restaurants');
  return restaurants.add({
    'avgRating': restaurant.avgRating,
    'category': restaurant.category,
    'city': restaurant.city,
    'name': restaurant.name,
    'numRatings': restaurant.numRatings,
    'photo': restaurant.photo,
    'price': restaurant.price,
  });
}

The preceding code adds a new document to the restaurants collection.

You do this by first getting a reference to a Cloud Firestore collection restaurants and then adding the data.

The document data comes from a Restaurant object, which needs to be converted to a Map for the Firestore plugin.

Add some restaurants

  1. Rebuild and refresh your Flutter App (Shift + R in the terminal window running your app).
  2. Click ADD SOME.

The app automatically generates a random set of restaurants objects, and calls your addRestaurant function. However, you won't see the data in your web app because you still need to implement retrieving the data (the next section of the codelab).

If you navigate to the Develop > Database > Cloud Firestore tab in the Firebase console, you should see new documents in the restaurants collection!

Congratulations, you just wrote data to Cloud Firestore from a web app!

In the next section, you learn how to retrieve data from Cloud Firestore and display it in your app.

In this section, you learn how to retrieve data from Cloud Firestore and display it in your app. The two key steps are creating a query and listening on its Stream of snapshots. This listener is notified of all existing data that matches the query and receives updates in real time.

First, construct the query that serves the default, unfiltered list of restaurants.

  1. Go back to the file lib/src/model/data.dart.
  2. Find the function loadAllRestaurants.
  3. Replace the entire function with the following code.

lib/src/model/data.dart

Stream<QuerySnapshot> loadAllRestaurants() {
  return Firestore.instance
      .collection('restaurants')
      .orderBy('avgRating', descending: true)
      .limit(50)
      .snapshots();
}

The preceding code constructs a query that retrieves up to 50 restaurants from the top-level collection named restaurants, ordered by their average rating (currently all zero).

Now, you need to transform each QuerySnapshot returned from the Stream into Restaurant data that you can render.

To extract Restaurant information from a QuerySnapshot from the restaurants collection:

  1. Go back to the file lib/src/model/data.dart.
  2. Find the function getRestaurantsFromQuery.
  3. Replace the entire function with the following code:

lib/src/model/data.dart

List<Restaurant> getRestaurantsFromQuery(QuerySnapshot snapshot) {
  return snapshot.documents.map((DocumentSnapshot doc) {
    return Restaurant.fromSnapshot(doc);
  }).toList();
}

The getRestaurantsFromQuery method is called every time there's a new QuerySnapshot of the Query you created earlier. QuerySnapshots are the mechanism that Firestore uses to notify your app of changes to a Query, in real time.

This method simply converts all the documents contained in the snapshot into Restaurant objects that can be used elsewhere in your Flutter app.

Now that you implemented both methods, rebuild and reload your app, and verify that the restaurants you saw earlier in the Firebase console are visible in the app. If you completed this section successfully, then your app is reading and writing data with Cloud Firestore!

As your list of restaurants changes, this listener updates automatically. Try going to the Firebase console and manually deleting a restaurant or changing its name—you'll see the changes show up on your site immediately!

So far, you learned how to use onSnapshot to retrieve updates in real time; however, that's not always what you want. Sometimes, it makes sense to only fetch the data once.

You need a method that loads a specific restaurant from its ID, for when users click a specific restaurant in the app.

  1. Go back to your file lib/src/model/data.dart.
  2. Find the function getRestaurant.
  3. Replace the entire function with the following code:

lib/src/model/data.dart

Future<Restaurant> getRestaurant(String restaurantId) {
  return Firestore.instance
      .collection('restaurants')
      .document(restaurantId)
      .get()
      .then((DocumentSnapshot doc) => Restaurant.fromSnapshot(doc));
}

The code uses get() to retrieve a Future<DocumentSnapshot> containing the information of the restaurant you requested. You just need to pipe that through then() to a function that converts the DocumentSnapshot into a Restaurant object whenever it's ready.

After you implement this method, you can view each restaurant's detail page.

  1. Refresh your app by pressing Shift + R in the terminal where flutter is running.
  2. Click a restaurant in the list, and you should see the restaurant's details page:

Next, you add the code needed to add ratings to restaurants using transactions.

In this section, you add the ability for users to submit reviews to restaurants. So far, all of your writes have been atomic and relatively simple. If any of the writes errored, then you'd likely just prompt the user to retry the write, or your app would retry the write automatically.

Your app will have many users who want to add a rating for a restaurant, so you need to coordinate multiple reads and writes. First, the review has to be submitted, and then the restaurant's rating count and average rating need to be updated. If one fails but not the other, you're left in an inconsistent state. The data in one part of the database doesn't match the data in another.

Fortunately, Cloud Firestore provides transaction functionality that allows you to perform multiple reads and writes in a single, atomic operation, ensuring that your data remains consistent.

  1. Go back to your file lib/src/model/data.dart.
  2. Find the function addReview.
  3. Replace the entire function with the following code:

lib/src/model/data.dart

Future<void> addReview({String restaurantId, Review review}) {
  final restaurant =
      Firestore.instance.collection('restaurants').document(restaurantId);
  final newReview = restaurant.collection('ratings').document();

  return Firestore.instance.runTransaction((Transaction transaction) {
    return transaction
        .get(restaurant)
        .then((DocumentSnapshot doc) => Restaurant.fromSnapshot(doc))
        .then((Restaurant fresh) {
      final newRatings = fresh.numRatings + 1;
      final newAverage =
          ((fresh.numRatings * fresh.avgRating) + review.rating) / newRatings;

      transaction.update(restaurant, {
        'numRatings': newRatings,
        'avgRating': newAverage,
      });

      return transaction.set(newReview, {
        'rating': review.rating,
        'text': review.text,
        'userName': review.userName,
        'timestamp': review.timestamp ?? FieldValue.serverTimestamp(),
        'userId': review.userId,
      });
    });
  });
}

The preceding function triggers a transaction that starts by fetching a fresh version of the Restaurant represented by restaurantId.

Then, you update the numeric values of avgRating and numRatings in the restaurant document reference.

At the same time, you add the new review through the newReview document reference into the ratings subcollection of the restaurant.

To test the code that you just added:

  1. Refresh your app by pressing Shift + R in the terminal where flutter is running.
  2. Go to any Restaurant detail page.
  3. Add some reviews. You can do this by:

Currently, the app displays a list of restaurants, but there's no way for the user to filter based on their needs. In this section, you use Cloud Firestore's advanced querying to enable filtering.

Here's an example of a simple query to fetch all Dim Sum restaurants:

Query filteredCollection = Firestore.instance
        .collection('restaurants')
        .where('category', isEqualTo: 'Dim Sum');

As its name implies, the where() method makes the query download only members of the collection whose fields meet the restrictions you set. In this case, it only downloads restaurants where the category is equal to Dim Sum.

Similarly, you can sort the returned data:

Query filteredAndSortedCollection = Firestore.instance
        .collection('restaurants')
        .where('category', isEqualTo: 'Dim Sum')
        .orderBy('price', descending: true);

The orderBy() method makes the query sort the Dim Sum restaurants by their price attribute, from most to least expensive.

In the app, the user can chain multiple filters to create specific queries, like Pizza in San Francisco or Seafood in Los Angeles, ordered by popularity.

You create a method that builds a query that filters the restaurants based on multiple criteria selected by the users.

  1. Go back to your file lib/src/model/data.dart.
  2. Find the function loadFilteredRestaurants.
  3. Replace the entire function with the following code:

lib/src/model/data.dart

Stream<QuerySnapshot> loadFilteredRestaurants(Filter filter) {
  Query collection = Firestore.instance.collection('restaurants');
  if (filter.category != null) {
    collection = collection.where('category', isEqualTo: filter.category);
  }
  if (filter.city != null) {
    collection = collection.where('city', isEqualTo: filter.city);
  }
  if (filter.price != null) {
    collection = collection.where('price', isEqualTo: filter.price);
  }
  return collection
      .orderBy(filter.sort ?? 'avgRating', descending: true)
      .limit(50)
      .snapshots();
}

The preceding code adds multiple where filters and a single orderBy clause to build a compound query based on user input. Now the query only returns restaurants that match the user's requirements.

Refresh your app in your browser by pressing Shift + R in the terminal where flutter is running.

Now, try to filter by price, city, and category. While testing, you'll see errors in the JavaScript Console of your browser that look like this:

The query requires an index. You can create it here: https://console.firebase.google.com/project/.../database/firestore/indexes?create_index=...

These errors happen because Cloud Firestore requires indexes for most compound queries. Requiring indexes on queries keeps Cloud Firestore fast at scale.

Opening the link from the error message automatically opens the index creation UI in the Firebase console with the correct parameters filled in.

In the next section, you write and deploy the indexes needed for this application, all at once, from the Firebase CLI.

If you don't want to explore every path in the app and follow each of the index creation links, you can easily deploy many indexes at once using the Firebase CLI.

  1. In the root of your app project, find the firestore.indexes.json file.

This file describes all the indexes needed for all the possible combinations of filters.

firestore.indexes.json

{
 "indexes": [
   {
     "collectionGroup": "restaurants",
     "queryScope": "COLLECTION",
     "fields": [
       { "fieldPath": "city", "order": "ASCENDING" },
       { "fieldPath": "avgRating", "order": "DESCENDING" }
     ]
   },

   ...

 ]
}
  1. Deploy these indexes with the following command:
firebase deploy --only firestore:indexes

After a few minutes, your indexes become live, and the error messages go away. If you try to use the indexes before they're fully ready, you might see errors similar to these:

The query requires an index. That index is currently building and cannot be used yet. See its status here:
https://console.firebase.google.com/project/.../database/firestore/indexes?create_index=...

At the beginning of this codelab, you set your app's security rules to completely open the database to any read or write. In a real application, you'd set much more fine-grained rules to prevent undesirable data access or modification.

  1. In the Firebase console's Develop section, click Database.
  2. Click the Rules tab in the Cloud Firestore section (or go directly to the Firebase console).
  3. Replace the defaults with the following rules, and click Publish.

firestore.rules

service cloud.firestore {
  match /databases/{database}/documents {
    // Restaurants:
    //   - Authenticated user can read
    //   - Authenticated user can create/update (for demo)
    //   - Validate updates
    //   - Deletes are not allowed
    match /restaurants/{restaurantId} {
      allow read, create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
      allow delete: if false;
      
      // Ratings:
      //   - Authenticated user can read
      //   - Authenticated user can create if userId matches
      //   - Deletes and updates are not allowed
      match /ratings/{ratingId} {
        allow read: if request.auth != null;
        allow create: if request.auth != null
                      && request.resource.data.userId == request.auth.uid;
        allow update, delete: if false;
      }
    }
  }
}

These rules restrict access, to ensure that clients only make safe changes, for example:

Instead of using the Firebase console, you can use the Firebase CLI to deploy rules to your Firebase project. The firestore.rules file in your working directory already contains the preceding rules. To deploy these rules from your local filesystem (rather than using the Firebase console), run the following command:

firebase deploy --only firestore:rules

flutter build web

So far, you used only "debug" versions of your Flutter app. Those builds are a bit slower, because they contain extra information to make debugging easier.

Before you deploy your app, you need to build a production (prod) version. Flutter lets you build for production with the build tool:

flutter build web

This places all the production-built assets into the build/web directory of the project.

Your app is now ready to be deployed to Firebase!

firebase deploy

Your project is preconfigured to build and deploy the assets that Flutter generates (take a look at the firebase.json file in the root of the project).

Deploy a new version of your app with Firebase using a single command:

firebase deploy --only hosting

The preceding process should take only a few seconds. It cleans up any prior builds (flutter clean), re-builds your app (flutter build web), and deploys the freshly built assets (the contents of build/web) to Firebase Hosting.

The success message contains a Hosting URL where your published app is now available on the internet!

Congratulations!!!

In this codelab, you learned how to connect your Flutter web app to Firebase using the Firebase Auth and Firestore plugins, performed basic and advanced reads and writes with Cloud Firestore, and secured data access with security rules.

You can find the full solution in the done branch of the repository.

To learn more about Dart and Flutter, take a look at their official sites:

To learn more about Cloud Firestore, visit the following resources:

This codelab is a good starting point to explore other Firestore (and Firebase!) features. If you want an additional challenge, you may try to: