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.
If you're not very familiar with Flutter or Firestore, first complete the Firebase for Flutter codelab:
To complete this codelab, you need:
beta
or newer) with web support enabled. You configure web support during this codelab, but for more information, see the web support for Flutter page.npm
tool, for installing the official firebase
command-line tools for the final part of this codelab (Deploy indexes, Secure your data, and Deploy to Firebase Hosting).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.
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:
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.
The app uses Cloud Firestore to save and receive restaurant information and ratings.
To enable Cloud Firestore:
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
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.
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:
lib/src/model/data.dart
: The main file that you modify during this codelab. It contains all the logic to read and write data from Firestore.web/index.html
: The file that the browser loads to start your application. You modify this file to install and initialize the Firebase libraries for the web.The Firebase command-line interface (CLI) allows you to deploy your web app and configuration to Firebase directly from files in your project.
npm -g install firebase-tools
firebase --version
Make sure that the version of the Firebase CLI is v7.4.0 or later.
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:
firebase use --add
An alias is useful if you have multiple environments (production, staging, and so on). However, for this codelab, just use the alias of default
.
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 |
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>
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 = {
...
web/index.html
file, and click Continue to console in the Add Firebase to your web app dialog.
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 |
You should see the following dialog:
open ios/Runner.xcworkspace
to open Xcode.GoogleService-Info.plist
.GoogleService-Info.plist
file (that you just downloaded) into that Runner subfolder.
You're done configuring your Flutter app for iOS!
Configure Android |
You'll should see the following dialog :
android/app/src/main/AndroidManifest.xml
.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.google-services.json
.google-services.json
file (that you just downloaded) into the android/app
directory.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.
flutter run -d chrome
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.
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
.
The main model object in the app is a restaurant. Next, write some code that adds a restaurant document to the restaurants
collection.
lib/src/model/data.dart
.addRestaurant
.lib/src/model/data.dart
Future<void> addRestaurant(Restaurant restaurant) {
final restaurants = FirebaseFirestore.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.
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.
lib/src/model/data.dart
.loadAllRestaurants
.lib/src/model/data.dart
Stream<QuerySnapshot> loadAllRestaurants() {
return FirebaseFirestore.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:
lib/src/model/data.dart
.getRestaurantsFromQuery
.lib/src/model/data.dart
List<Restaurant> getRestaurantsFromQuery(QuerySnapshot snapshot) {
return snapshot.docs.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.
lib/src/model/data.dart
.getRestaurant
.lib/src/model/data.dart
Future<Restaurant> getRestaurant(String restaurantId) {
return FirebaseFirestore.instance
.collection('restaurants')
.doc(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.
flutter
is running.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.
lib/src/model/data.dart
.addReview
.lib/src/model/data.dart
Future<void> addReview({String restaurantId, Review review}) {
final restaurant =
FirebaseFirestore.instance.collection('restaurants').doc(restaurantId);
final newReview = restaurant.collection('ratings').doc();
return FirebaseFirestore.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,
});
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:
flutter
is running.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 = FirebaseFirestore.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 = FirebaseFirestore.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.
lib/src/model/data.dart
.loadFilteredRestaurants
.lib/src/model/data.dart
Stream<QuerySnapshot> loadFilteredRestaurants(Filter filter) {
Query collection = FirebaseFirestore.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.
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" }
]
},
...
]
}
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.
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
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!
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:
addRestaurantsBatch
method to add all restaurants and reviews in a single request, so the UI of your application updates only once.RestaurantAppBar
widget so it updates the star rating of a restaurant in real time, as users add reviews to it.firebase_auth
with google_sign_in
to retrieve the actual first name of the user who's posting a review because all users are currently anonymous users.