Let's Build a Cross-Platform Chat App in Flutter

Let's Build a Cross-Platform Chat App in Flutter
Generated by ChatGPT

Why Flutter?

Once upon a time, I was thrilled to find a perfect note-taking called Bear because of its simplicity, pretty UI, inexpensive, and most importantly, its cute bear logo. Sadly, the app is not available on Android. As a developer, it would be delightful if my apps could be available on all platforms with minimal effort (without having to build the app all over again for another platform). Fortunately, Google has released Flutter — a cross-platform framework written in Dart — officially on December 4th, 2018. Flutters’ target is not just Android or iOS, but wherever users exist including mobile, web, and desktop.

Overview

Today, we’re going to build a simple real-time messaging application for Android and iOS using Flutter and Firebase (Firebase Auth, Firebase Database, Firebase Storage). Here are the screenshots of the finished app:

Let’s Get Started

Friendly Chat is a pretty popular Google’s sample application. To set up Flutter environment and build a beautiful UI, we can start here. After finishing the tutorial, we should have an application with a similar UI to the finished one. Now, we are going to integrate Firebase into our app.

Integrate Firebase

  1. First of all, we need to add Firebase to our Friendly Chat App on Firebase Console as guided here.
  2. After that, we can add all Firebase dependencies that Friendly Chat needs in pubspec.yaml file:
dependencies:
  flutter:
    sdk: flutter  firebase_core: ^0.4.0+6
  firebase_database: ^3.0.3
  firebase_auth: ^0.11.1+7
  firebase_storage: ^3.0.2

Note: These dependencies are the latest Firebase dependencies at the time of this writing. The latest Firebase dependencies might require migrating to AndroidX.

Push Messages to Firebase Database

DatabaseFirebase is a class that acts as the main access point to the database while DatabaseReference is a class that references a specific portion of the database. In this case, the DatabaseReference object references the messaging portion of the database.

//...
import 'package:firebase_database/firebase_database.dart';
//...
final FirebaseDatabase _database = FirebaseDatabase.instance;
final DatabaseReference  _messageDatabaseReference = FirebaseDatabase.instance.reference().child("messages");

From the tutorial of Google CodeLabs, we already had _handleSubmitted callback when clicking the send button. Now, we only need to modify the function to push the message to the database every time we send a message. To upload a message to the database, we use the previous reference to push the corresponding JSON:

void _handleSubmitted(String text) {
  _textController.clear();
  setState(() { _isComposing = false; });  
  final ChatMessage message = _createMessageFromText(text);
  _messageDatabaseReference.push().set(message.toMap());
}

ChatMessage _createMessageFromText(String text) => ChatMessage(
    text: text,
    username: _name,
    animationController: AnimationController(
      duration: Duration(milliseconds: 180),
      vsync: this,
    ),
  );

Firebase helps us write simple code that can interact with a scalable database. On top of that, it provides an easy-to-use UI to check messages in the database. By now, you might have noticed that the database is not updated when we send new messages. This is because of restrictions from the default database rules. For more information on database rules, check out this documentation. Fortunately, we only need this simple rule for testing purposes:

{
  "rules": {
    "messages": {
      ".read": "auth != null",
      ".write": "auth != null",
    }
  }
}

Only authenticated users can now read and write the messages node. Naturally, the next step is to implement Authentication.

Implement Firebase Authentication

To avoid reinventing the wheel, we can follow David Cheah’s instructions to create a login page and authenticate users with Firebase and Flutter. If you don’t want to read the whole post, you can just simply take a look at David’s repo (or mine), which is pretty simple and easy to understand.

Save Images to Firebase Storage

Why should we bother using Firebase Storage when we already had Firebase Database?

The reason is performance. We probably don’t want to store images in the Firebase Real-time Database as it will prolong load time. Firebase Storage is designed for rich-content documents such as general files, images, and videos.

What is the difference then?

Firebase Storage can handle uploading and downloading files in a robust fashion. When users lose their network connection in the middle of the process, Firebase Storage can continue downloading or uploading where it was before.

Similar to the real-time database, we need an entry point:

//...
import 'package:firebase_storage/firebase_storage.dart';
//...
final FirebaseStorage _firebaseStorage = FirebaseStorage.instance;
final StorageReference _photoStorageReference = _firebaseStorage.ref().child("chat_photos");

To send images, there is a convenient Dart package designed by Google called image_picker. Thus, we should add the dependency to pubspec.yaml:

image_picker: ^0.6.0+9

Let’s discuss the flow of saving and displaying images. First of all, the users either choose a photo from the gallery or the camera. Luckily, with image_picker, we don’t have to manage that. The package would simply return a File after a user chooses an image (we’ll see how simple that is shortly). With that file, we will send it directly to Firebase Storage, where we can access it through a URL. In addition, we also have to push the URL to the Firebase Database, so the Widget would know how to display the image through the URL (instead of the text of a message).

//...
import 'package:image_picker/image_picker.dart';
//...
Widget _buildTextComposer() {
  return IconTheme(
      data: IconThemeData(color: Theme.of(context).accentColor),
      child: Container(
        margin: const EdgeInsets.symmetric(horizontal: 8.0),
        child: Row(
          children: <Widget>[
            Flexible(
              child: TextField(
                controller: _textController,
                onChanged: (String text) {
                  setState(() {
                    _isComposing = text.length > 0;
                  });
                },
                onSubmitted: _handleSubmitted,
                decoration: InputDecoration.collapsed(
                  hintText: "Send a message",
                ),
              ),
            ),
            Container(
                margin: const EdgeInsets.symmetric(horizontal: 4.0),
                child: Row(
                  children: <Widget>[
                    IconButton(
                      icon: Icon(Icons.camera_alt),
                      onPressed: _sendImageFromCamera,
                    ),
                    IconButton(
                      icon: Icon(Icons.image),
                      onPressed: _sendImageFromGallery,
                    ),
                    Theme.of(context).platform == TargetPlatform.iOS
                        ? CupertinoButton(
                            child: Text("Send"),
                            onPressed: _isComposing
                                ? () => _handleSubmitted(
                                      _textController.text,
                                    )
                                : null,
                          )
                        : IconButton(
                            icon: Icon(Icons.send),
                            onPressed: _isComposing
                                ? () => _handleSubmitted(
                                      _textController.text,
                                    )
                                : null,
                          ),
                  ],
                ))
          ],
        ),
      ));
}

void _onMessageAdded(Event event) {
  final text = event.snapshot.value["text"];
  final imageUrl = event.snapshot.value["imageUrl"];
  ChatMessage message = imageUrl == null
      ? _createMessageFromText(text)
      : _createMessageFromImage(imageUrl);
      
  setState(() { _messages.insert(0, message); });
  
  message.animationController.forward();
}

void _handleSubmitted(String text) {
  _textController.clear();
  setState(() { _isComposing = false; });
  
  final ChatMessage message = _createMessageFromText(text);
  _messageDatabaseReference.push().set(message.toMap());
}

void _sendImageFromCamera() async {
  _sendImage(ImageSource.camera);
}

void _sendImageFromGallery() async {
  _sendImage(ImageSource.gallery);
}

ChatMessage _createMessageFromText(String text) => ChatMessage(
    text: text,
    username: _name,
    animationController: AnimationController(
      duration: Duration(milliseconds: 180),
      vsync: this,
    ),
  );
    
ChatMessage _createMessageFromImage(String imageUrl) => ChatMessage(
    imageUrl: imageUrl,
    username: _name,
    animationController: AnimationController(
      duration: Duration(milliseconds: 90),
      vsync: this,
    ),
  );

Now, we only have to implement _sendImage to upload the image to Firebase Storage. In this simple application, I will name the file using uuid:

//...
import 'package:uuid/uuid.dart';
//...

void _sendImage(ImageSource imageSource) async {
  File image = await ImagePicker.pickImage(source: imageSource);
  final String fileName = Uuid().v4();
  
  StorageReference photoRef = _photoStorageReference.child(fileName);
  
  final StorageUploadTask uploadTask = photoRef.putFile(image);
  final StorageTaskSnapshot downloadUrl = await uploadTask.onComplete;
  final ChatMessage message = _createMessageFromImage(
    await downloadUrl.ref.getDownloadURL(),
  );
  
  _messageDatabaseReference.push().set(message.toMap());
}

Of course, similar to Firebase Database, Firebase Storage also has security rules. Initially, we just need to set read and write permissions to true. For this simple messaging application, we only need to slightly modify it to allow read and write permissions for authenticated users with files that are smaller than 5 MB.

service firebase.storage {
  match /b/{bucket}/o {
    match /chat_photos/{imageId} {
      allow read: if request.auth != null;
      allow write: if request.auth != null && request.resource.size < 5 * 1024 * 1024
    }
  }
}

Complete Source Code

GitHub - trinhan-nguyen/friendly_chat: A Simple Real-time Messaging Application
A Simple Real-time Messaging Application. Contribute to trinhan-nguyen/friendly_chat development by creating an account on GitHub.

Reference

Flutter : How to do user login with Firebase
Release
Firebase, Cloud Storage and Flutter
Firebase, Cloud Storage and Flutter. Using Storage in Flutter