If you're familiar with object-oriented programming concepts, you should be able to complete this codelab. You don't need previous experience with Dart, mobile programming, or Firebase; although completing an introductory Flutter codelab first can be helpful.
Flutter and Firebase work hand-in-hand to help you build mobile apps in record time. Flutter is Google's SDK for building mobile apps for iOS and Android. Firebase gives you access to backend services for mobile applications—including authentication, storage, database, and hosting—without maintaining your own servers.
In this codelab, you'll learn how to create a Flutter app that uses Firebase. The app helps new parents choose baby names by letting friends and family vote for their favorites. Specifically, the app accesses a Cloud Firestore database, and a user action in your app (i.e., tapping a name option) updates the database using a transaction.
Here's what the final app will look like, on both iOS and Android. Yes, you read that right! With Flutter, when you build your app, you can use the same code for both iOS and Android!
Here's a short video that demonstrates building a similar app in real time. It provides a good overview of the steps you'll complete in this codelab.
You need the following pieces of software to complete this codelab:
You can run this codelab using one or more of the following devices:
baby_names
instead of myapp
. The instructions for creating a new Flutter app differ depending on your editor. If you're using an IDE, a new app is usually called a project.pubspec.yaml
. Add a dependency for cloud_firestore
, then save the file.dependencies:
flutter:
sdk: flutter
cloud_firestore: ^0.8.2 # new
flutter packages get
.If you get an error, make sure that the indentation in your dependencies
block is exactly as shown above, using two spaces (not a tab).
Flutter takes about a minute to build the app. The good news is that this is the last time you'll wait for compilation in this codelab—the rest of your changes will be hot-reloaded.
When your app is finished building, you should see the following app:
lib/main.dart
. This file currently contains the entire code for the default Flutter app.main.dart
, then replace it with the following:import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
final dummySnapshot = [
{"name": "Filip", "votes": 15},
{"name": "Abraham", "votes": 14},
{"name": "Richard", "votes": 11},
{"name": "Ike", "votes": 10},
{"name": "Justin", "votes": 1},
];
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Baby Names',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() {
return _MyHomePageState();
}
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Baby Name Votes')),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
// TODO: get actual snapshot from Cloud Firestore
return _buildList(context, dummySnapshot);
}
Widget _buildList(BuildContext context, List<Map> snapshot) {
return ListView(
padding: const EdgeInsets.only(top: 20.0),
children: snapshot.map((data) => _buildListItem(context, data)).toList(),
);
}
Widget _buildListItem(BuildContext context, Map data) {
final record = Record.fromMap(data);
return Padding(
key: ValueKey(record.name),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(5.0),
),
child: ListTile(
title: Text(record.name),
trailing: Text(record.votes.toString()),
onTap: () => print(record),
),
),
);
}
}
class Record {
final String name;
final int votes;
final DocumentReference reference;
Record.fromMap(Map<String, dynamic> map, {this.reference})
: assert(map['name'] != null),
assert(map['votes'] != null),
name = map['name'],
votes = map['votes'];
Record.fromSnapshot(DocumentSnapshot snapshot)
: this.fromMap(snapshot.data, reference: snapshot.reference);
@override
String toString() => "Record<$name:$votes>";
}
r
in the command line at the same directory location that you ran flutter run
.You should see the following app:
The app is currently just a mock. Clicking on names only prints to the console.
The next step is to connect this app to Cloud Firestore. Before doing that, you can read about how the code in main.dart
is structured.
If you don't have one, you'll need to create a Firebase account. A free plan is sufficient for this codelab (and most development for most apps).
After you've created a Firebase project, you can configure one (or more) apps to use that Firebase project. All you need to do is:
If you're developing your Flutter app for both iOS and Android, you need to register both the iOS and Android versions within the same Firebase project. If you're just developing for one platform, just skip the unneeded section.
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'll see the dialog shown in the following screencap:
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 see the dialog shown in the following screencap:
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.google-services.json
file that was generated by Firebase.android/app/build.gradle
, then add the following line as the last line in the file:apply plugin: 'com.google.gms.google-services'
android/build.gradle
, then inside the buildscript
tag, add a new dependency:buildscript {
repositories {
// ...
}
dependencies {
// ...
classpath 'com.google.gms:google-services:3.2.1' // new
}
}
You're done configuring your Flutter app for Android!
Your Flutter app should now be connected to Firebase.
Flutter provides access to a wide range of platform-specific services, including Firebase APIs and plugins. Plugins include platform-specific code to access services and APIs on iOS and Android.
Firebase is accessed through a number of different libraries, one for each Firebase product (for example, databases, authentication, analytics, storage). Flutter provides its own set of plugins to access each Firebase product, collectively called FlutterFire. Be sure to check the FlutterFire GitHub page for the most up-to-date list of FlutterFire plugins.
Your Firebase-Flutter setup is finished, and you're ready to start building your app!
You'll start by setting up Cloud Firestore and initializing it with some values.
Our database will have one collection, that we'll name "baby". In the collection is where the names and votes are stored.
baby
, then click Next.You can now add documents to your collection. Each document has a Document ID, and we'll need to have name and votes fields (as shown in the screencap below).
dana
.name
, select string
for the Type, then enter the Value of Dana
.votes
. Select number
for the Type, then initialize the Value as 0
.After adding several documents to your collection, your database should look something like this:
Our app is now connected to Cloud Firestore!
It's time to fetch our collection (baby
) and use it instead of our dummySnapshot
object.
From Dart, you get the reference to Cloud Firestore by calling Firestore.instance
. Specifically for our collection of baby names, call Firestore.instance.collection('baby').snapshots()
to return a stream
of snapshots.
Let's plug that stream of data into our Flutter UI using a StreamBuilder
widget.
lib/main.dart
, then find the _buildBody
method.Widget _buildBody(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: Firestore.instance.collection('baby').snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) return LinearProgressIndicator();
return _buildList(context, snapshot.data.documents);
},
);
}
DocumentSnapshot
to a method that expects something else. Find _buildList
and change its signature to this:Widget _buildList(BuildContext context, List<DocumentSnapshot> snapshot) {
...
Instead of a list of Map
, it now takes a list of DocumentSnapshot
.
_buildListItem
still thinks it's getting a Map
. Find the start of the method, then replace it with this:Widget _buildListItem(BuildContext context, DocumentSnapshot data) {
final record = Record.fromSnapshot(data);
Instead of a Map
, you're now taking a DocumentSnapshot
, and using the Record.fromSnapshot()
named constructor to build the Record
.
dummySnapshot
field from the top of lib/main.dart
. It's not needed anymore.r
in the command line at the same location that you ran flutter run
.After about a second, your app should look like this:
You've just read from the database that you created!
If you want, you can go to the Firebase console and change the database. Your app will reflect the changes almost immediately (after all Cloud Firestore is a real-time database!).
Next you will allow users to actually vote!
lib/main.dart
, find the line that says onTap: () => print(record)
. Change it to this:onTap: () => record.reference.updateData({'votes': record.votes + 1})
Instead of just printing the record to the console, this new line updates the baby name's database reference by incrementing the vote count by one.
Voting is now functional, including the update to the user interface.
How does this work? When the user taps the tile containing a name, you are telling Cloud Firestore to update the data of that reference. In turn, this causes Cloud Firestore to notify all listeners with the updated snapshot. As your app is listening through the StreamBuilder
implemented above, it's updated with the new data.
It's hard to spot when testing on a single device, but our current code creates a subtle race condition. If two people with your app vote at the same time, then the value of the votes field would increase by only one -- even though two people voted for the name. This is because both apps would read the current value at the same time, add one, then write the same value back to the database. Neither user would notice anything wrong because they would both see the value of votes increase. It's extremely difficult to detect this problem through testing because triggering the error depends on doing two things inside a very small time window.
The value of votes is a shared resource, and any time that you update a shared resource (especially when the new value depends on the old value) there is a risk of creating a race condition. Instead, when updating a value in any database, you should use a transaction.
lib/main.dart
, find the line that says onTap: () => record.reference.updateData({'votes': record.votes + 1})
. Replace it with this:onTap: () => Firestore.instance.runTransaction((transaction) async {
final freshSnapshot = await transaction.get(record.reference);
final fresh = Record.fromSnapshot(freshSnapshot);
await transaction
.update(record.reference, {'votes': fresh.votes + 1});
}),
The voting interaction now takes a bit more time to complete. The upside, though, is that each vote counts—you removed the race condition.
How does this work? By wrapping the read and write operations in one transaction, you're telling Cloud Firestore to only commit a change if there was no external change to the underlying data while the transaction was running. If two users aren't concurrently voting on that particular name, the transaction runs exactly once. But if the number of votes changes between the transaction.get(...)
and the transaction.update(...)
calls, the current run isn't committed, and the transaction is retried. After 5 failed retries, the transaction fails.
Here are the final contents of lib/main.dart
.
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Baby Names',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() {
return _MyHomePageState();
}
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Baby Name Votes')),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
return StreamBuilder<QuerySnapshot>(
stream: Firestore.instance.collection('baby').snapshots(),
builder: (context, snapshot) {
if (!snapshot.hasData) return LinearProgressIndicator();
return _buildList(context, snapshot.data.documents);
},
);
}
Widget _buildList(BuildContext context, List<DocumentSnapshot> snapshot) {
return ListView(
padding: const EdgeInsets.only(top: 20.0),
children: snapshot.map((data) => _buildListItem(context, data)).toList(),
);
}
Widget _buildListItem(BuildContext context, DocumentSnapshot data) {
final record = Record.fromSnapshot(data);
return Padding(
key: ValueKey(record.name),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(5.0),
),
child: ListTile(
title: Text(record.name),
trailing: Text(record.votes.toString()),
onTap: () => Firestore.instance.runTransaction((transaction) async {
final freshSnapshot = await transaction.get(record.reference);
final fresh = Record.fromSnapshot(freshSnapshot);
await transaction
.update(record.reference, {'votes': fresh.votes + 1});
}),
),
),
);
}
}
class Record {
final String name;
final int votes;
final DocumentReference reference;
Record.fromMap(Map<String, dynamic> map, {this.reference})
: assert(map['name'] != null),
assert(map['votes'] != null),
name = map['name'],
votes = map['votes'];
Record.fromSnapshot(DocumentSnapshot snapshot)
: this.fromMap(snapshot.data, reference: snapshot.reference);
@override
String toString() => "Record<$name:$votes>";
}
Congratulations!
You now know the basics of integrating Flutter apps with Firebase.
Ready to share your new app with friends? Check out how you can generate platform-specific binaries for your Flutter app (an IPA file for iOS and an APK file for Android).
For more information on Firebase, see:
You might also find these developer resources useful as you continue working with the Flutter framework:
Here are some ways to get the latest news about Flutter: