Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

16. Firebase Cloud Firestore
Written by Stef Patterson & Kevin D Moore

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

When you want to store information for many people, you can’t realistically store it on one person’s phone. It has to be stored in the cloud. You could hire a team of developers to design and implement a backend system that connects to a database via a set of APIs. But, this could take months. Wouldn’t it be great if you could just connect to an existing system?

This is where Firebase Cloud Firestore comes in. You no longer need to write complicated apps that use thousands of lines of async tasks and threaded processes to simulate reactiveness. With Cloud Firestore, you’ll be up and running in no time.

In this chapter, you’ll add an instant messaging feature to the Yummy app.

While adding this feature, you’ll learn:

  • About Cloud Firestore and when to use it.
  • The steps required to set up a Firebase project with the Cloud Firestore.
  • How to set up user authentication.
  • How to connect to, query and populate the Cloud Firestore.
  • How to use the Cloud Firestore to build your own instant messaging app.

Getting Started

First, open the starter project from this chapter’s project materials and run flutter pub get.

Next, build and run your project. You’ll see the Yummy app’s Chat tab.

Right now, your app doesn’t do much, but when you’re done, you’ll know how to use Cloud Firestore to send and receive messages.

What is Cloud Firestore?

Google has two NoSQL document databases within the Firebase suite of tools: Realtime Database and Cloud Firestore. But what’s the difference?

Setting Up a Firebase Project

Before you can use any of Google’s Cloud services, you have to create a project on the Firebase Console.

Installing Firebase CLI

What is Firebase CLI? It’s a Firebase management toolkit that enables running commands from command-line. Installation varies depending on your platform and preferred installation option — standalone binary or Node Package Manager (npm) that uses Node.js.

Using the Firebase CLI to Log In

To use Firebase from your IDE, you need to log in and select your project. Open Terminal and execute the following:

firebase login

Adding Firebase

The Firebase team has made things a lot easier for Flutter developers. You used to have to set up iOS, Android, and web apps separately. Now, you can add a Flutter app, and Firebase will do all the work for you.

dart pub global activate flutterfire_cli
flutterfire configure --project=kodecochat-XXXXX

  firebase_auth: ^4.14.1
  firebase_core: ^2.23.0
  cloud_firestore: ^4.13.2
await Firebase.initializeApp(
  options: DefaultFirebaseOptions.currentPlatform,
);
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';

Adding Authentication

Firebase enables you to add user authentication without having to write and maintain your own server-side code. This can save you a lot of time and effort.

Setting up Firebase Authentication

Return to the Firebase console in the browser. Click the Authentication card.

Understanding Firestore Data Storage

Cloud Firestore stores data in Documents that are like JSON dictionaries in key/value pairs. These pairs are called fields. Documents can also contain nested subcollections and arrays.

{
  "name": "Jane Doe",
  "department": 250,
  "occupation": "Flutter Developer"
}
[
  {
    "name": "Jane Doe",
    "department": 250,
    "occupation": "Flutter Developer"
  },
  {
    "name": "John Doe",
    "department": 500,
    "occupation": "Flutter Developer"
  }
]

Creating Cloud Firestore Database

Return to the Firebase Project Overview page.

Firebase Security Rules

Firebase database security consists of rules that limit who can read and/or write to specific paths. The rules consist of a JSON string in the Rules tab.

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

Modeling Data

Data modeling is an important part of your app development process. By creating a data model, you can ensure that the data is organized and stored in a way that is efficient, scalable and secure.

Creating User Data Access Object (DAO)

In lib/models, create a new file named user_dao.dart and add the following:

import 'dart:developer';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

// 1
class UserDao extends ChangeNotifier {
  String errorMsg = 'An error has occurred.';

  // 2
  final auth = FirebaseAuth.instance;

  // TODO: Add helper methods
}
// 1
bool isLoggedIn() {
  return auth.currentUser != null;
}
// 2
String? userId() {
  return auth.currentUser?.uid;
}
//3
String? email() {
  return auth.currentUser?.email;
}

// TODO: Add signup

Signing Up

The first task for a user is to create an account. Replace // TODO: Add signup with:

// 1
Future<String?> signup(String email, String password) async {
  try {
    // 2
    await auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
    // 3
    notifyListeners();
    return null;
  } on FirebaseAuthException catch (e) {
    // 4
      if (email.isEmpty) {
        errorMsg = 'Email is blank.';
      } else if (password.isEmpty) {
        errorMsg = 'Password is blank.';
      } else if (e.code == 'weak-password') {
        errorMsg = 'The password provided is too weak.';
      } else if (e.code == 'email-already-in-use') {
        errorMsg = 'The account already exists for that email.';
      }
   return errorMsg;
  } catch (e) {
    // 5
    log(e.toString());
    return e.toString();
  }
}

// TODO: Add login

Logging In

Once a user has created an account, they can log in. Replace // TODO: Add login with:

// 1
Future<String?> login(String email, String password) async {
  try {
    // 2
    await auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
    // 3
    notifyListeners();
    return null;
  } on FirebaseAuthException catch (e) {
    // 4
    if (email.isEmpty) {
      errorMsg = 'Email is blank.';
    } else if (password.isEmpty) {
      errorMsg = 'Password is blank.';
    } else if (e.code == 'invalid-email') {
      errorMsg = 'Invalid email.';
    } else if (e.code == 'INVALID_LOGIN_CREDENTIALS') {
      errorMsg = 'Invalid credentials.';
    } else if (e.code == 'user-not-found') {
      errorMsg = 'No user found for that email.';
    } else if (e.code == 'wrong-password') {
      errorMsg = 'Wrong password provided for that user.';
    }
    return errorMsg;
  } catch (e) {
    // 5
    log(e.toString());
    return e.toString();
  }
}

// TODO: Add logout

Logging Out

The final feature is log out. Replace // TODO: Add logout with:

void logout() async {
  await auth.signOut();
  notifyListeners();
}

Adopting Riverpod

As you saw in Chapter 13, “Managing State”, Riverpod is a great package for providing classes to its children. Your screens need access to these DAO classes. To do that, you’ll create two providers: one for user data and the other for messages.

import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'models/user_dao.dart';

// 1
final userDaoProvider = ChangeNotifierProvider<UserDao>((ref) {
  return UserDao();
});

// TODO: Add messageDaoProvider

// TODO: Add messageListProvider

Creating the Login Screen

To use your app, a user needs to log in. To do that, they need to create an account. You’ll create a dual-use login screen that will allow a user to either log in or sign up for a new account.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';

class Login extends ConsumerStatefulWidget {
   const Login({
    super.key,
  });

  @override
  ConsumerState createState() => _LoginState();
}

class _LoginState extends ConsumerState<Login> {
  // 1
  final _emailController = TextEditingController();
  // 2
  final _passwordController = TextEditingController();
  // 3
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  @override
  void dispose() {
    // 4
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

// TODO: Add build
@override
Widget build(BuildContext context) {
  // 1
  final userDao = ref.watch(userDaoProvider);
  return Scaffold(
     body: Padding(
      padding: const EdgeInsets.all(32.0),
      // 2
      child: Form(
        key: _formKey,

        // TODO: Add Column & Email
child: Column(
  children: [
    Padding(
        padding: const EdgeInsets.symmetric(vertical: 10.0),
        // 1
        child: TextFormField(
          decoration: const InputDecoration(
            border: UnderlineInputBorder(),
            hintText: 'Email Address',
          ),
          autofocus: false,
          // 2
          keyboardType: TextInputType.emailAddress,
          // 3
          textCapitalization: TextCapitalization.none,
          autocorrect: false,
          // 4
          controller: _emailController,
          // 5
          validator: (String? value) {
            if (value == null || value.isEmpty) {
              return 'Email Required';
            }
            return null;
          },
        ),
    ),
    // TODO: Add Password
  Padding(
      padding: const EdgeInsets.symmetric(vertical: 10.0),
      child: TextFormField(
        decoration: const InputDecoration(
          border: UnderlineInputBorder(),
          hintText: 'Password',
        ),
        autofocus: false,
        obscureText: true,
        keyboardType: TextInputType.visiblePassword,
        textCapitalization: TextCapitalization.none,
        autocorrect: false,
        controller: _passwordController,
        validator: (String? value) {
          if (value == null || value.isEmpty) {
            return 'Password Required';
          }
          return null;
        },
      ),
    ),
    const Spacer(),
// TODO: Add Buttons
  SizedBox(
    width: double.infinity,
    child: ElevatedButton(
      // 1
      onPressed: () async {
        if (_formKey.currentState!.validate()) {
          final errorMessage = await userDao.login(
            _emailController.text,
            _passwordController.text,
          );
          // 2
          if (errorMessage != null) {
            if (!mounted) return;
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text(errorMessage),
                duration: const Duration(milliseconds: 700),
              ),
            );
          }
        }
      },
      child: const Text('Login'),
    ),
  ),
  Padding(
    padding: const EdgeInsets.symmetric(vertical: 10.0),
    child: SizedBox(
      width: double.infinity,
      child: ElevatedButton(
        // 3
        onPressed: () async {
          if (_formKey.currentState!.validate()) {
            final errorMessage = await userDao.signup(
              _emailController.text,
              _passwordController.text,
            );
            if (errorMessage != null) {
              if (!mounted) return;
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(errorMessage),
                  duration: const Duration(milliseconds: 700),
                ),
              );
            }
          }
        },
        child: const Text('Sign Up'),
      ),
    ),
  ),
// TODO: Add parentheses
            ],
          ),
        ),
      ),
    );
  }
}
final userDao = ref.watch(userDaoProvider);
Center(
  child: userDao.isLoggedIn()
      ? const MessageList()
      : const Login(),
),
import '../components/login.dart';
import 'providers.dart';

Adding a Logout Button

Still in home.dart, replace // TODO: Replace with logout button with:

IconButton(
  onPressed: () {
    userDao.logout();
  },
  icon: const Icon(Icons.logout),
),

Adding Message Data Model

Create a new file in the lib/components directory called message.dart. Then, add the following class with date, email, text and reference properties:

import 'package:cloud_firestore/cloud_firestore.dart';

class Message {
  Message({
    required this.date,
    required this.email,
    required this.text,
    this.reference,
  });

  final DateTime date;
  final String email;
  final String text;

  DocumentReference? reference;

  // TODO: Add JSON converters
}
// 1
factory Message.fromJson(Map<dynamic, dynamic> json) => Message(
      date: (json['date'] as Timestamp).toDate(),
      email: json['email'] as String,
      text: json['text'] as String,
    );

// 2
Map<String, dynamic> toJson() => <String, dynamic>{
      'date': date,
      'email': email,
      'text': text,
    };

// TODO: Add fromSnapshot
factory Message.fromSnapshot(DocumentSnapshot snapshot) {
  // 1
  final message = Message.fromJson(
    snapshot.data() as Map<String, dynamic>,
  );
  // 2
  message.reference = snapshot.reference;
  return message;
}

Adding Message DAO

Create a new file in lib/models called message_dao.dart. This is your DAO for your messages.

import 'package:cloud_firestore/cloud_firestore.dart';
import '../components/message.dart';
import 'user_dao.dart';

class MessageDao {
  MessageDao(this.userDao);

  final UserDao userDao;

  // 1
  final CollectionReference collection =
      FirebaseFirestore.instance.collection('messages');

  // TODO: Add saveMessage
}
void sendMessage(String text) {
  // 1
  final message = Message(
    date: DateTime.now(),
    email: userDao.email()!,
    text: text,
  );
  // 3
  collection.add(message.toJson()); // 2
}

// TODO: Add getMessageStream
Stream<List<Message>> getMessageStream() {
  return collection
    .orderBy('date', descending: true)
    .snapshots()
    .map((snapshot) {
      return [...snapshot.docs.map(Message.fromSnapshot)];
    });
}
final messageDaoProvider = Provider<MessageDao>((ref) {
  return MessageDao(ref.watch(userDaoProvider));
});
final messageListProvider = StreamProvider<List<Message>>((ref) {
  final messageDao = ref.watch(messageDaoProvider);
  return messageDao.getMessageStream();
});
import 'components/message.dart';
import 'models/message_dao.dart';

Creating New Messages

Open components/message_list.dart. Replace // TODO: Replace _sendMessage and the line beneath it with your new send message code:

void _sendMessage() {
  if (_messageController.text.isNotEmpty) {
    // 1
    final messageDao = ref.read(messageDaoProvider);
    // 2
    messageDao.sendMessage(_messageController.text.trim());
    _messageController.clear();
  }
}
import '../providers.dart';

Reactively Displaying Messages

Now that you have a stream of messages, you want to display them.

const MessageWidget(
  this.message, {
    super.key,
  });

final Message message;
// 1
final userDao = ref.watch(userDaoProvider);
//2
final myMessage = message.email == userDao.email();
import 'package:intl/intl.dart';
import '../providers.dart';
import 'message.dart';
Text(
  message.text,
  style: theme.textTheme.bodyLarge!,
),
Row(
    // TODO: Add mainAxisAlignment
    children: [
      // Display email of others not ones sent from device
      !myMessage
        ? Text(
            message.email,
            style: TextStyle(
              color: theme.colorScheme.secondary,
            ),
          )
          // If message is sent from the device display nothing
        : const Text(''),
      // Display date and time message was sent
      Text(
        '  ${DateFormat.yMd().format(message.date)} '
        '${DateFormat.Hm().format(message.date)}',
        style: TextStyle(
          color: theme.colorScheme.secondary,
        ),
       ),
    ],
),
crossAxisAlignment: myMessage //
  ? CrossAxisAlignment.end
  : CrossAxisAlignment.start,
alignment: myMessage //
  ? Alignment.topRight
  : Alignment.topLeft,
mainAxisAlignment: myMessage //
  ? MainAxisAlignment.end
  : MainAxisAlignment.start,
Expanded(
  // 1
  child: Consumer(
    builder: (BuildContext context, WidgetRef ref, Widget? child) {
      final data = ref.watch(messageListProvider);
      return data.when(
        loading: () => const Center(
          child: LinearProgressIndicator(),
        ),
        data: (List<Message> messages) => ListView(
        controller: _scrollController,
        reverse: true,
        // 2
        children: [
          for (final message in messages) //
            Padding(
              padding:
                const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 4.0),
              child: MessageWidget(message),
            ),
          ],
        ),
        error: (error, stackTrace) {
          return Center(child: Text('$error'));
        },
      );
    },
  ),
),
import 'message.dart';
import 'message_widget.dart';

Key Points

  • Cloud Firestore is a good solution for low-latency database storage.
  • FlutterFire provides an easy way to use Firebase packages.
  • Firebase provides serverless authentication and security through Rules.
  • Creating data access object (DAO) files helps to put Firebase functionalities in one place.
  • Use Firestore to store and retrieve data in real time.
  • You can choose many different types of authentication, from email to other services.

Where to Go From Here?

There are plenty of other Cloud Firestore features that can supercharge your app and give it enterprise-grade features. These include:

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now