How to Integrate Push Notifications Into Your Applications Using Strapi and Firebase Cloud Messaging

How to Integrate Push Notifications Into Your Applications Using Strapi and Firebase Cloud Messaging

Develop a microblogging application to learn how to use a Strapi instance to trigger push notifications to various platforms.

Push notifications are interactive clickable pop-up dialog messages. They made their debut in 2009 when Apple integrated them into some of their devices. Over the coming years, major tech players such as Google and Windows launched their implementations for their platforms.

In 2010, Google launched Google Cloud-to-Device messaging service(C2DM), which introduced notifications to Android devices. Two years later, Google Cloud Messaging was unveiled to replace C2DM. Developers were encouraged to use the new service as it brought about a larger messaging payload, improved authentication flows, and removed API rate limits. C2DM was officially shut down in July 2015.

Push notifications are based on a PubSub (Publish/Subscribe) communication model. A server that acts as a publisher sends information(notifications) to clients that have subscribed to a specific channel.

Whenever new information is available at a channel, all clients subscribed to it receive and display it on the device’s screen. In addition, some even play sounds and flash LEDs depending on the device’s hardware features. Mobile devices are the most popular client devices, but desktops have recently been able to receive notifications mainly through web browsers such as Google Chrome and Mozilla Firefox.

There are two types of push notifications: local and remote. Local notifications are triggered from within the device’s operating system, while remote notifications are triggered through HTTP requests. This tutorial will walk you through developing remote notifications using Google’s Firebase Cloud Messaging Platform.

Prerequisites

  • NodeJS installation

  • Beginner Flutter Knowledge

  • An Integrated Development Environment, I use Vscode, but you are free to use others.

  • Prior Strapi knowledge is helpful but not required - Learn the basics of Strapi v4.

  • Basic JSON Web Token Authentication Knowledge.

The stack: Strapi, Flutter, and Firebase Notifications

Strapi is a headless Content Management System(CMS). This means it handles all the logic needed to transform and store data in a database without a user interface. Strapi exposes API endpoints that can manipulate data stored in a database. Furthermore, it handles authentication and provides numerous methods to plugin third-party authentication systems like Google and Facebook Auth. Such features shorten project development timelines enabling developers to focus on the User Interfaces instead of the back-end logic and process.

Flutter is a cross-platform framework built on the dart programming language. It gets to benefit from features such as Just-in-time(JIT) and Ahead of time(AOT) compilation which are brought about by dart. These attributes help improve the overall start-up time of a flutter application. In addition, Flutter uses the Skia Graphics engine to render User interface components called widgets on a target platform. Like the Strapi development server, Flutter has a hot reload feature, which makes the app development process extremely fast since code changes are reflected as soon as edits are saved.

Finally, we will use Firebase, which is Google’s platform that enables programmers to build serverless applications that can be released and monitored easily. It can also start user engagement campaigns through its cloud messaging plugins. Firebase can also be set up to real-time application stack analytics, which functions similarly to Sentry. A Strapi server can be configured to log all bugs recorded from Firebase analytics.

The stack we will use enables us to code once and build for many platforms. Strapi handles authentication, authorization, and permissions needed to manipulate the data we will generate from our Flutter application. Flutter supports iOS, Android, Mac OS, Windows, Web, and Google Fuchsia out of the box. This speeds up our development time. We will use Firebase’s cloud messaging plugin to send push notifications to all platforms by triggering functions from our Strapi backend.

Initializing a Strapi Project

To get started, we will have to set up our project folder, consisting of the Strapi instance(back-end) and the Flutter application(front-end). Since Strapi is based on JavaScript, it depends on Node. Make sure your development machine has the appropriate Node version installed for Strapi to build and run. Node enables us to run JavaScript code outside of a browser.

Run the following command to create a scaffold of the Strapi server.

    npx create-strapi-app@latest backend --quickstart

The command creates a bare-bone Strapi system, fetches and installs necessary dependencies from the package.json file then finally initializes an SQLite Database. Other database management systems can be used using the following guide.

Once every dependency has been installed, your default browser will open and render the admin registration page for Strapi. Create your administrator account then you will see the welcome page.

Strapi Admin Welcome Screen

Creating Collections

We will use Strapi’s content-type builder plugin to define a schema that will guide the database on how we want our records stored. We will have three schemas, two of which we will generate ourselves. The create-strapi-app command generated the user schema, it contains the necessary fields needed to identify a user of the system.

Quote Collection

This collection will store a user’s quote(post).

  1. Click on Content-type Builder under plugins in the side navigation bar.

  2. Click on Create new collection type.

  3. Type Quote for the Display name and click Continue.

  4. Click the Text field button.

  5. Type d``e``scription in the Name field.

  6. Click on Add another field**, **then click the relation button

  7. Type owner in the field name text field.

    A Quote can only have one user/ A user can have many quotes

The relation field is used to create an association between two database tables. In our case, we are associating each post with a user. We can get all the user’s details from the user-permissions plugin.

The relation we have created ensures that each quote belongs to only one user. It can also be expressed as a user can have many quotes. The owner column in the quote table will be used to store the foreign key, the user’s id.

  1. Click the Save button and wait for the changes to be applied.

Quote Collection Configuration

Like Collection

We will create another schema that will represent the likes of a quote. We will create a relational field to ensure that each quote can have many likes and that each like is associated with only one user. Follow the steps below to implement that.

  1. Click on Content-type Builder under plugins in the side navigation bar.

  2. Click on Create new collection type.

  3. Type Like for the Display Name and click Continue.

  4. Click the Relation field button.

    One-to-many relationship

The relation above ensures that each like is associated with a quote. A single quote can have many likes. The field name likes will create a link visible from the Quote collection.

  1. Click on Add another field, then the relation field button.

    add another field

The field name owner links to the Users table and stores the user’s id. We can identify which user has liked a specific post through our created relations. In addition, we can get the number of likes a specific post has.

Extending the User Collection

When we generated the Strapi project, a schema was generated to allow us to identify the different users that will be using our application. The schema describes fields such as username, email, and password. It also has a relational field that is used to assign roles. Roles are essential when creating applications with different user types that require various permissions to manipulate records in a database.

Since we will send personalized push notifications to our users, we need to add a field that will store a unique string that identifies the user’s device. Follow the steps below to create it.

  1. Click on Content-type Builder under plugins in the side navigation bar.

  2. Click on User, the last collection under the collection types list.

  3. On the top right, click on Add another field.

  4. Click on the Text field button.

  5. Enter fcm as the field’s name. Do not change the default short text option.

  6. Click the Save button and wait for the changes to be applied.

User Schema Configuration

The final configuration of the user’s collection should have a quotes relation field which was automatically added when we created an association while setting up the quote collection. The fcm field will store the Firebase Cloud Messaging(FCM) token. Each device has a unique token used to receive push notifications.

We will be invalidating that token every time the user logs out. If we fail to do so, when a different user logs in, they will receive the previously logged user’s notifications. So will have to send a new token immediately after a user has signed in. This will automatically update the fcm value stored if it exists on the database.

Extending the users-permission plugin.

The users-permission plugin handles authentication in all Strapi projects. It supports different authentication providers like Google Auth, Facebook, Twitter, and GitHub. Once a user has successfully logged in or registered, the plugin generates a JSON Web Token (jwt), bundles it with some helpful info such as the email, username, and id then sends it to the client.

The client then uses the token to access secured requests by appending the token to the request header. For each protected HTTP route, the plugin checks whether the token is valid and if it is associated with a user with the necessary access permissions. If you want to view how exactly it achieves this, you can view the source code on GitHub or by navigating within the node_modules/@strapi/plugin-users-permissions folder with the project directory.

We need to extend this plugin to enable us to save the Firebase messaging token. We will create a new POST route specifically for this action. Follow the steps below to get started.

  1. Create a file called strapi-server.js within extensions/users-permissions. This file will hold all the logic we want to add to the plugin.

  2. Finally, we will add the code below.


    //./src/extensions/user-permissions/strapi-server.js

    module.exports = (plugin) => {
    /**
    Appends a function that saves the messaging token from a client device to the plugin's controller
    **/
    plugin.controllers.auth.saveFCM = async (ctx) => {
    var res = await strapi.entityService.update('plugin::users-permissions.user', ctx.state.user.id, { data: { fcm: ctx.request.body.token } });
            ctx.body = res;
        };
    /**
    Adds a POST method route that is handled by the saveFCM function above.
    **/
    plugin.routes['content-api'].routes.push({
            method: 'POST',
            path: '/auth/local/fcm',
            handler: 'auth.saveFCM',
            config: {
                prefix: '',
                policies: []
            }
    });

    return plugin;

    };

The code block declares a module that exports a modified version of the users-permissions plugin. The first function within it adds a function called saveFCM to the auth controller. This function will be invoked when a POST request is made at the endpoint /auth/local/fcm. We are using the user identified from the jwt token to update the fcm field. The plugin parsed the request body from a JSON string to a JavaScript object. This makes it easy to fetch the value associated with the key called token. The value populates the fcm field in the User collection. Once the token has been saved, we return the user’s details.

The following function registers the /auth/local/fcm in the plugin. We specified it is a POST request, and its request parameters and response will be handled by the saveFCM function we had defined. After modifying the plugin, we return it to apply our changes.

To confirm whether the new route has been successfully added run strapi routes:list on your console. The command outputs all valid routes that the Strapi server has exposed.

Table showing all strapi routes.

We need to make our newly created route accessible to all HTTP clients. We must specify whether the route is protected or a publicly accessible HTTP endpoint.

  1. On the side navigation menu of the administration panel, click on Settings

  2. Click on Roles under the Users & Permissions Plugin menu.

  3. Click on Authenticated then expand the users-permissions section by clicking on it.

  4. Click on saveFCM and ensure it is checked.

user-permissions strapi

The authenticated section will let us know with which user we will associate the FCM token. After the user-permissions plugin has identified the user and saved it in the connection, we can access the user’s details and create an association between the user and Firebase Cloud messaging token retrieved from the POST request body params.

Firebase Setup

We need to create a Firebase project and register our target platforms so that we may be able to get the messaging tokens and send notifications by triggering actions from the Strapi server.

  1. Open the firebase console on your default web browser and ensure you are signed into your Google account.

  2. Click on Add Project and enter Quotes as the name, then click continue.

  3. The next screen displays some benefits of enabling Google analytics on the project. We will leave the default option selected. Click continue to proceed.

  4. Select Default Account For Firebase on the drop-down then finally click Create Project.

Firebase Console Project Analytics Setup Screen

Once the project has been created successfully, we need to create a Firebase service account, a JSON file containing details required to authenticate to Firebase Servers. The file will configure Firebase on the Strapi server through the Firebase admin SDK npm library.

  1. On the top left, click the Settings icon, then select Project settings.

  2. On the Project settings home page, click on the Service accounts tab.

  3. Make sure Node.js is selected, then click Generate new private key. A dialog will pop up explaining the importance of keeping the key private. Confirm by clicking the Generate Key button.

  4. Once the file has been downloaded move it to the src folder in our Strapi project directory.

  5. We need to install the Firebase admin library so that our server can authenticate with Firebase private key we have just downloaded. On your console, make sure you are within the project’s directory then run the command below to install firebase-admin.

    $ npm install firebase-admin --save
    # Or using yarn
    $ yarn add firebase-admin

The command will add the Firebase admin SDK library in the package.json file. Once it lists it as a dependency, we can import it and initialize it with the private key JSON file.

Firebase has two pricing models: blaze plan and pay-as-you-go. The blaze plan is the default one. It is sufficient for our project because Cloud messaging invocations are not capped to a specific number; this enables us to use the feature without ever paying.

Firebase Functions

The Firebase admin library enables us to trigger notifications and manage Firebase sessions, users, tokens, storage, and databases. We will create several functions interacting with Firebase’s cloud messaging features. We will also make the authenticated Firebase instance available from anywhere within the Strapi server by saving it in a Strapi object. To start, open the index.js file in the src folder within the project directory. Add the code blocks below.


    // ./src/index.js
    'use strict';
    var admin = require("firebase-admin");
    var serviceAccount = require("./yourPrivateKeyFile.json");
    module.exports = {
      /**
       * An asynchronous register function that runs before
       * your application is initialized.
       */
       register({ strapi }) {},
      /**
       * An asynchronous bootstrap function that runs before
       * your application gets started.
       *
       */
      bootstrap({ strapi }) {
        let firebase = admin.initializeApp({
          credential: admin.credential.cert(serviceAccount),
        });
        //Make Firebase available everywhere
        strapi.firebase = firebase;
        let messaging = firebase.messaging();

        let sendNotification = (fcm, data) => {
          let message = {
            ...data,
            token: fcm
          }
          messaging.send(message).then((res) => {
            console.log(res);
          }).catch((error) => {
            console.log(error);
          });
        }
        let sendNotificationToTopic = (topic_name, data) => {
          let message = {
            ...data,
            topic: topic_name
          }
          messaging.send(message).then((res) => {
            console.log(res);
          }).catch((error) => {
            console.log(error);
          });
        }
        let subscribeTopic = (fcm, topic_name) => {
          messaging.subscribeToTopic(fcm, topic_name).then((res) => {
            console.log(res);
          }).catch((error) => {
            console.log(error);
          });
        }
        //Make the notification functions available everywhere
        strapi.notification = {
          subscribeTopic,
          sendNotificationToTopic,
          sendNotification
        }
      },
    };

The index.js file is used to extend default Strapi functions. The file contains two asynchronous functions invoked before the Strapi server starts. We initialized the Firebase SDK within the bootstrap function and passed the private key as a parameter in the admin.credential.cert function. We then create a key firebase in the Strapi object, which will make all Firebase functions available in all controllers.

The functions sendNotification, subscribeTopic and sendNotificationToTopic are also added to the Strapi object within the key name notification. The sendNotification function takes an FCM token and data as parameters. The data parameter is a custom object with all information needed to send it as a notification. The object is then appended to the message variable containing a token key with the user’s target device messaging token.

The sendNotificationToTopic takes two parameters: a data object and a target topic. A topic is a notification channel that links to many tokens. We could send a single notification to users subscribed to a topic. The last function, subcribeTopic associates a messaging token to a specific topic. It takes two parameters, the name of the topic and the messaging token, passed to Firebase's subscribeToTopic function.

Saving relations between collections

Both the quote collection and like collections are associated with a user. By default, those relationships are not added automatically; therefore, we need to override Strapi’s default functions to add that functionality.

Thanks to the users-permission plugin, it will be easy since the user making the request is already saved in the connection. We just need to extract and link it to the data we want to save. All these actions will be done within the respective collection’s controller.

Each route is mapped to a controller function. We will extend the create function invoked when a POST request is made. In our case, the controller functions that handle POST requests made to /api/quotes/ and /api/likes/

  1. Quotes Open src/api/quotes/controllers/quote.js and modify it to the code block below.

    //./src/api/quotes/controllers/quotes.js
    'use strict';
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::quote.quote', ({ strapi }) => ({
      create(ctx) {
          return strapi.service('api::quote.quote').createQuote({
                data: {
                    ...ctx.request.body.data,
                    owner: ctx.state.user.id
                }
            });
      },
      async find(ctx) {
        //Get Quotes with loaded relations
        let entities = await strapi.service('api::quote.quote').loadQuotes();
        entities = entities.map((entity) => {
        //Check if the authenicated User has liked the quote
        var result = entity.likes.find(like => like.owner.id === ctx.state.auth.credentials.id);
                return { ...entity, liked: result ? true : false }
            });
            return entities;
        }
    }));

The create function is the default function when a POST request is made on /api/quote. The request body parameters are already parsed and converted into a JavaScript object making it castable to the params required by createQuote . We also appended the user id under the owner key, which links the quote to a user.

The find function is invoked when a GET request is sent on the route /api/quote. By default, it returns a list of all quotes. However, we extended it to add a boolean field(liked) whose value is based on whether the authenticated user has liked the quote. We use the field to render Icon widgets on our flutter-powered front-end conditionally.

  1. Likes

Open the file ./src/api/like/controllers/like.js``, then modify it to the code block below.


    'use strict';
    /**
     *  like controller
     */
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::like.like', ({ strapi }) => ({
        async create(ctx) {
            let result = await strapi.service('api::like.like').createLike({
                ...ctx.request.body.data,
                owner: ctx.state.user.id
            });
            var quote = await strapi.service('api::quote.quote').findQuote(ctx.request.body.data.quote);
            var owner = await strapi.entityService.findOne(
                'plugin::users-permissions.user', quote.owner.id);
            strapi.notification.sendNotification(owner.fcm, {
                notification: {
                    title: `${ctx.state.user.username} liked your quote`,
                    body: `${quote.description}`
                }
            });
            ctx.body = result;
        }
    }));

We only updated the create function invoked when a POST request is made to the route /api/like. We ensured that a like is associated with the authenticated user. We then use the quote id from the request body param to get a quote. The returned quote contains the owner field which is used to fetch user details of the quote's author.

We then used the FCM field from the found user to send a notification to the author using the functions we made global in ./src/index.js file. We composed a notification body that shows the name of the person who liked the quote and the description of the quote. After the notification is sent, we return the details of the newly created like to the user.

The create function is called on POST requests made at /api/collection_name/ The find function is called on GET requests made at /api/collection_name/

That’s all that’s required from the Strapi-powered back-end. We can now consume the APIs through HTTP requests and send notifications to any client platform.

Initializing the Flutter project

To get started with flutter, you need to have your Android SDK and Flutter CLI configured in your path. A chromium-based browser is advantageous since it will enable us to test our application on the web. Flutter can detect Google Chrome and Microsoft Edge as target devices since they are built on the chromium platform.

To confirm whether your flutter is correctly configured run the command below on your console.

    flutter doctor

The command generates a report of your flutter installation. Known issues will be displayed if they are detected. Use this guide to configure your flutter for your development platform.

Output of the flutter doctor command

Initialize the project by running flutter create quotes on your console. The command will create a simple application that uses Material Design components and fetch basic dependencies needed to run the app on any platform. We will not be focusing too much on the user interface but feel free to add animations and drawable assets to make the final application look more appealing. Once the command has run successfully, the project structure should be as shown below.

Flutter Project Structure

The flutter create command generated a simple stateful counter application, an equivalent of a basic hello world application. To run the application, execute flutter run on your console. Suppose you have an Android emulator running or a developer mode to enable an Android device connected to your development machine.

In that case, the application should start after some time, depending on the specifications of your machine. The first run and build are usually longer compared to other consecutive runs. Flutter will select an appropriate platform to run on if you do not have an Android device linked to your machine. However, if you have more than one device connected at a time, the console should give you the option to select your target platform.

Console output if more than one devices are available.

We will add a screens folder to our flutter project. The folder will hold all the pages needed. Each page will handle its logic. Feel free to use any architecture to structure your application logically. You could play around with Model-View-Controller(MVC) while following this tutorial.

Within the screen folder, add the following files: form.dart, home.dart and login.dart. Immediately after the application starts, we will check whether a user had logged in before and then render the homepage(home.dart). We will render the login page(login.dart) if not authenticated.

Dependencies

There are additional flutter dart libraries needed to make our application. We have to install Firebase, the client-side SDK that will communicate with the Strapi back-end to display notifications and some analytics in the Firebase console. We also need an HTTP library to send HTTP requests to our Strapi server. Lastly, we need a way to persist an authenticated user’s details locally.

We will use a key-value database library called Hive. It is relatively faster compared to other methods of saving data locally. Even though our application is small, it is important to consider performance. Proper planning will ease scaling tasks later when the number of users increases. Run the command below to install the dependencies.

    flutter pub add flutter_local_notifications hive

Console output after installing the dependencies.

Firebase Setup

To start using Firebase client SDKs in flutter, we need to have the firebase-tools npm library installed globally. Make sure you have Node.js( v14.18.0 or later) installed, then run npm install -g firebase-tools. The -g flag ensures that the library is installed globally. Installing a node library globally enables us to access it from any directory in the command console. Follow the steps below once the command has successfully run.

  1. We must log into the account we used to create the project in the Firebase console. Run the command firebase login to initiate the login process. Your default browser will be opened and render the Google Accounts login page.

  2. Once logged in, run the command dart pub global activate flutterfire_cli on the console. The global command argument works the same way as the -g flag in the npm install command. We can run any flutterfire command from any directory.

  3. After installing the FlutterFire CLI, we will connect our flutter application to Firebase. Run flutterfire configure on your console.

  • Select the Project you had created in the Firebase console.

  • Select your target platforms.

  • Completion

The Flutterfire configure command guides you through associating a Firebase project to a flutter application. Furthermore, it modifies the application’s file structure by adding a configuration file called firebase_options.dart, within the lib directory. The config file contains details in the Private Key JSON file we downloaded from the Firebase console. Within the android directory, a JSON file has also been created. It contains authentication details required by our android build to communicate with the Firebase server.

Once we run our application after setting the Firebase client SDK app using the flutterfire configure, the target platforms we selected will be listed on our Firebase Project Settings page.

Android, iOS and Web Entries as target platforms.

Notifications Configuration

After successfully setting up the Firebase using flutterfire configure, we need to register Firebase in the app’s entry point named main.dart. The file will initialize our application each time it is loaded into a device’s memory. We will also check the authentication state to render the appropriate initial page. If the authentication state is null, the first page will be the login screen; else, the home page containing all quotes will be loaded.

Firstly we must import the necessary dependencies and two screens: the login and home screens. We will then define an asynchronous function responsible for handling our background notifications. Within it, we will initialize a Firebase instance passing default platform configurations. The main function is the first function that will be invoked after the application has been loaded to a platform’s memory.


    import 'package:flutter/material.dart';
    import 'package:hive_flutter/hive_flutter.dart';
    import 'package:firebase_core/firebase_core.dart';
    import 'package:firebase_messaging/firebase_messaging.dart';
    import 'package:quotes/screens/home.dart';
    import 'package:quotes/screens/login.dart';
    import 'firebase_options.dart';
    import 'package:flutter_local_notifications/flutter_local_notifications.dart';
    import 'package:flutter/foundation.dart';
    import 'dart:async';

    Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
      await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
      print('Handling a background message ${message.messageId}');
    }

    late AndroidNotificationChannel channel;
    late FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;

    Future<void> main() async {
      await Hive.initFlutter();
      await Hive.openBox('authToken');
      WidgetsFlutterBinding.ensureInitialized();

      await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform,
      );

      FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
      if (!kIsWeb) {
        channel = const AndroidNotificationChannel(
          'high_importance_channel', // id
          'High Importance Notifications', // title
          description:
              'This channel is used for important notifications.', // description
          importance: Importance.high,
        );
        flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
        await flutterLocalNotificationsPlugin
            .resolvePlatformSpecificImplementation<
                AndroidFlutterLocalNotificationsPlugin>()
            ?.createNotificationChannel(channel);
        await FirebaseMessaging.instance
            .setForegroundNotificationPresentationOptions(
          alert: true,
          badge: true,
          sound: true,
        );
      }
      runApp(const MyApp());
    }

Within the main function, we initialize a hive box responsible for storing the value of our JWT authentication token. We then call the WidgetsFlutterBinding.ensureIntialized() function to ensure that an instance of the widget binding class is created to use our target platform channels to call native code. The function interacts with the flutter engine to achieve native platform code invocation.

The Firebase.initializeApp() function also calls native code to initialize Firebase. It interacts with the config file(./app/google-services.json) generated by the flutterfire configure command with the android directory. We then initialize notification settings for non-Web platforms using the flutter_local_notification library we installed. This ensures that platform-specific notification APIs can be accessed.

Since we are using the stateful widgets, we will be able to use the initState function to listen for Firebase notification events while the application runs in the foreground of the device.


    class MyApp extends StatefulWidget {
      const MyApp({Key? key}) : super(key: key);
      @override
      State<MyApp> createState() => _MyAppState();
    }
    class _MyAppState extends State<MyApp> {
      @override
      void initState() {
        super.initState();
        FirebaseMessaging.instance
            .getInitialMessage()
            .then((RemoteMessage? message) {
          if (message != null) {
            print(message);
          }
        });
        FirebaseMessaging.onMessage.listen((RemoteMessage message) {
          RemoteNotification? notification = message.notification;
          AndroidNotification? android = message.notification?.android;
          if (notification != null && android != null && !kIsWeb) {
            flutterLocalNotificationsPlugin.show(
              notification.hashCode,
              notification.title,
              notification.body,
              NotificationDetails(
                android: AndroidNotificationDetails(
                  channel.id,
                  channel.name,
                  channelDescription: channel.description,
                  icon: 'launch_background',
                ),
              ),
            );
          }
        });
        FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
          print('A new onMessageOpenedApp event was published!');
          print("Notification Opened");
        });
      }
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: ValueListenableBuilder(
            valueListenable: Hive.box('authToken').listenable(),
            builder: (context, Box box, _) {
              if (box.isEmpty) {
                return Login(
                  box: box,
                );
              } else {
                return Home(
                  box: box,
                );
              }
            },
          ),
        );
      }
    }

Login Screen

This page will be rendered if Hive fails to return a saved user. This contains a login and registration form. We will use stateful flutter widgets to switch between these two forms based on a state value.

We will begin by importing the necessary dependencies into the file. Then define a class named Login that extends the stateful widget class. The class’ constructor will expect a hive box to be passed to it. A hive box is a named data store that saves data in key-value pairs. We will pass the hive box from the parent, the main.dart. View the code block below.


    import 'dart:convert';
    import 'dart:io';
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:hive/hive.dart';
    import 'package:http/http.dart' as http;
    class Login extends StatefulWidget {
      const Login({Key? key, required this.box}) : super(key: key);
      final Box box;
      @override
      State<Login> createState() => _LoginState();
    }
    class _LoginState extends State<Login> {
      bool newAccount = false;
      String get hostname {
        if (kIsWeb) {
          return 'http://localhost:1337';
        } else {
          return 'http://10.0.3.2:1337';
        }
      }
      Map<String, String> headers = {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
      };
      TextEditingController usernameController = TextEditingController();
      TextEditingController emailController = TextEditingController();
      TextEditingController passwordController = TextEditingController();
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            body: Container(
                padding: const EdgeInsets.all(10),
                child: newAccount ? registerView() : loginView()));
      }
    }

We defined a class named _LoginState that extends the state class, which expects a class extending the stateful widget class. A boolean variable named newAccount was initialized to false and is used to render the login and register form. The hostname function returns the URL, which will be used to send requests to the server depending on the platform running the application.

The kIsWeb variable is defined within the foundation library we imported. Its value is true if the current platform is web. Else false when the application runs on Mobile, Linux, Windows, and macOS. We will be sending requests to the Strapi server through the host addresses localhost or 127.0.0.1 on the web. 10.0.3.2 will be used by Genymotion Emulators, and 10.0.2.2 for Android Studio Based emulators. Those addresses are mapped to the host computer while emulating an android device. You can read more about it here.

All requests we send will be in JSON format; therefore, we must set the appropriate request headers through a map. We used text editing controllers to get string values from the material UI text fields. The scaffold widget is the root widget for our screen; it contains a container child that sets a padding value. This prevents the forms from touching the edges of the screen. The padding widget creates spaces on all edges(top, bottom, right, and left).

Login Form

This widget function is invoked conditionally depending on the value of the newAccount variable. The parent widget is a column that ensures that child widgets are arranged vertically. It contains two text fields: the email text field, which captures the username/email entered, and the password text field. Each text field is assigned its respective text editing controller.

The next widget is a text button responsible for calling the post function from the HTTP library we had imported. The last widget is a text button that changes the state of the screen. It modifies the value of the newAccount variable, which determines which widget function will be called. Each time the state changes, the screen renders with the appropriate state.


    Widget loginView() {
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: emailController,
              decoration: const InputDecoration(
                  border: InputBorder.none,
                  labelText: 'Username/Email Address',
                  hintText: 'example@example.com'),
            ),
            TextField(
              obscureText: true,
              controller: passwordController,
              decoration: const InputDecoration(
                  border: InputBorder.none,
                  labelText: 'Password',
                  hintText: 'Enter your password'),
            ),
            TextButton(
                onPressed: () async {
                  try {
                    final response = await http.post(
                      Uri.parse('$hostname/api/auth/local/'),
                      headers: headers,
                      body: jsonEncode(<String, String>{
                        'password': passwordController.text,
                        'identifier': emailController.text,
                      }),
                    );
                    var responseData = json.decode(response.body);
                    widget.box.put("token", responseData["jwt"]);
                    widget.box.put("username", responseData\["user"\]["username"]);
                  } on Exception catch (e) {
                    print(e);
                  }
                },
                child: const Text("Login")),
            TextButton(
                onPressed: () {
                  setState(() {
                    newAccount = true;
                  });
                },
                child: const Text("Register")),
          ],
        );
    }

The email and password values are retrieved from the text editing controllers the converted into a JSON string using the jsonEncode function from the converted library. Once a request is sent to /api/auth/local/ , and we change the JSON response to a map object and then access the values using their respective keys. The expected response contains the jwt token and user details. We save these details in the hive box because we will use them on other screens. The jwt token is the most important response value because it will authenticate requests to other HTTP endpoints.

Register Form

This widget function is rendered when the value of the newAccount variable is true. It is structured the same way as the login form but has an additional text field that captures a username. Strapi allows us to sign in with either an email or username. The last text button modifies the value of the newAccount variable. When set to false the login form is rendered.


    Widget registerView() {
        return Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: usernameController,
              decoration: const InputDecoration(
                  border: InputBorder.none,
                  labelText: 'Username',
                  hintText: 'Enter Your Username'),
            ),
            TextField(
              controller: emailController,
              decoration: const InputDecoration(
                  border: InputBorder.none,
                  labelText: 'Email Address',
                  hintText: 'example@example.com'),
            ),
            TextField(
              controller: passwordController,
              decoration: const InputDecoration(
                  border: InputBorder.none,
                  labelText: 'Password',
                  hintText: 'Enter Your Password'),
            ),
            TextButton(
                onPressed: () async {
                  try {
                    final response = await http.post(
                      Uri.parse('$hostname/api/auth/local/register'),
                      headers: headers,
                      body: jsonEncode(<String, String>{
                        'username': usernameController.text,
                        'password': passwordController.text,
                        'email': emailController.text,
                      }),
                    );
                    var responseData = json.decode(response.body);
                    widget.box.put("token", responseData["jwt"]);
                    widget.box.put("username", responseData\["user"\]["username"]);
                  } on Exception catch (e) {
                    print(e);
                  }
                },
                child: const Text("Create account")),
            TextButton(
                onPressed: () {
                  setState(() {
                    newAccount = false;
                  });
                },
                child: const Text("Login")),
          ],
        );
    }

Home screen

This screen is responsible for loading and rendering all quotes from the Strapi server. This page is only rendered when a user is authenticated. It also fetches the Firebase cloud messaging token and sends it to the server.

Before the screen loads, we will send an authenticated GET request to the Strapi server and get a list of quotes. The initState function is called before the widget is rendered on the device screen.

We will override it and add some logic to fetch the Firebase messaging token and fetch the quotes. In addition, we will check whether the messaging token value has been set on hive. If not, we fetch the token using Firebase's getToken function. A web push certificate key is needed if the current platform is Web. It is found on the cloud messaging tab in the project’s settings on the Firebase console. Add the code below within your ./lib/screens/home.dart


    //./lib/screens/home.dart
    @override
    void initState() {
        super.initState();
        if (!widget.box.containsKey("fcm")) {
          if (kIsWeb) {
            //TODO: Get your Web Push Certificate Key from Firebase
            FirebaseMessaging.instance
                .getToken(
                    vapidKey:
                        'INSERT YOUR WEB PUSH CERTIFICATE KEY HERE')
                .then(setToken);
          } else {
            //If Running android
            FirebaseMessaging.instance.getToken().then(setToken);
          }
        }
        setState(() {
          username = widget.box.get("username");
        });
        _tokenStream = FirebaseMessaging.instance.onTokenRefresh;
        _tokenStream.listen(setToken);
        fetchQuotes();
    }

    void setToken(String? token) async {
        if (token != null) {
          await http.post(
            Uri.parse('$hostname/api/auth/local/fcm'),
            headers: headers(),
            body: jsonEncode(<String, String>{
              'token': token,
            }),
          );
          widget.box.put("fcm", token);
        }
    }

Displaying the Quotes

The quotes will be rendered within a ListView widget, each being embedded within a dismissible widget. This enables the user to swipe horizontally to delete a specific quote. Once the swipe gesture has been detected on a quote, a dialog asking for confirmation will be displayed. If the action is confirmed an authenticated DELETE request is sent to the endpoint /api/quote/quote_id. A user can only delete their quotes. We check if the usernames match before displaying the delete dialog.


    Widget renderQuotes(BuildContext context) {
        return ListView.builder(
            itemCount: quotes.length,
            itemBuilder: ((context, index) {
              return Dismissible(
                key: Key(quotes\[index\]["id"].toString()),
                confirmDismiss: (DismissDirection direction) async {
                  if (quotes\[index\]["owner"]["username"] == username) {
                    return await showDialog(
                      context: context,
                      builder: (BuildContext context) {
                        return AlertDialog(
                          title: const Text("Confirm"),
                          content: const Text(
                              "Are you sure you wish to delete this item?"),
                          actions: [
                            TextButton(
                                onPressed: () async {
                                  var quoteId = quotes\[index\]["id"].toString();
                                  await http.delete(
                                      Uri.parse('$hostname/api/quotes/$quoteId'),
                                      headers: headers());
                                  fetchQuotes();
                                  Navigator.of(context).pop(true);
                                },
                                child: const Text("Delete")),
                            TextButton(
                              onPressed: () => Navigator.of(context).pop(false),
                              child: const Text("Cancel"),
                            ),
                          ],
                        );
                      },
                    );
                  } else {
                    return false;
                  }
                },
                onDismissed: (direction) {
                  ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('Successfully Deleted')));
                },
                background: Container(color: Colors.red),
                child: ListTile(
                  title: Text(quotes\[index\]["description"]),
                  subtitle: Text(quotes\[index\]["owner"]["username"] == username
                      ? "Me"
                      : quotes\[index\]["owner"]["username"]),
                  trailing: Column(
                    children: [
                      GestureDetector(
                          onTap: (() async {
                            if (quotes\[index\]["liked"]) {
                              ScaffoldMessenger.of(context)
                                  .showSnackBar(const SnackBar(
                                content: Text("Already liked"),
                              ));
                            } else {
                              var like = jsonEncode(<String, Map<String, String>>{
                                "data": {"quote": quotes\[index\]["id"].toString()}
                              });
                              await http.post(
                                Uri.parse('$hostname/api/likes'),
                                headers: headers(),
                                body: like,
                              );
                              fetchQuotes();
                            }
                          }),
                          child: Icon(Icons.favorite,
                              color: quotes\[index\]["liked"]
                                  ? Colors.red
                                  : Colors.blue)),
                      Text(quotes\[index\]["likes"].length.toString())
                    ],
                  ),
                ),
              );
            }));
      }

Form Screen

This screen is responsible for posting a new quote to the Strapi server. It contains only one form field within a stateless widget class. The screen is an immutable widget because it will not be rebuilt once rendered on the screen.

Like in the login screen, we use the text editing controller to get typed text from the form field. The quote description is sent via an authenticated POST request to the HTTP route /api/quotes. If the quote is successfully posted, we check the response status code and then navigate back to the home screen.

    import 'dart:convert';
    import 'dart:io';
    import 'package:flutter/foundation.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter/src/foundation/key.dart';
    import 'package:flutter/src/widgets/framework.dart';
    import 'package:hive_flutter/hive_flutter.dart';
    import 'package:http/http.dart' as http;
    class AddForm extends StatelessWidget {
      AddForm({Key? key, required this.box}) : super(key: key);
      final Box box;
      TextEditingController quoteController = TextEditingController();
      String get hostname {
        if (kIsWeb) {
          return 'http://localhost:1337';
        } else {
          return 'http://10.0.3.2:1337';
        }
      }
      Map<String, String> headers() {
        var authToken = box.get("token");
        return {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'Authorization': 'Bearer $authToken'
        };
      }
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
              title: Text("New Quote"),
            ),
            body: Container(
              padding: EdgeInsets.all(10),
              child: Column(children: [
                TextField(
                  controller: quoteController,
                  decoration: const InputDecoration(
                      border: InputBorder.none,
                      labelText: "What's on your mind?",
                      hintText: 'Enter Your Name'),
                ),
                TextButton(
                    onPressed: () async {
                      var quote = jsonEncode(<String, Map<String, String>>{
                        "data": {"description": quoteController.text.toString()}
                      });
                      final response = await http.post(
                        Uri.parse('$hostname/api/quotes'),
                        headers: headers(),
                        body: quote,
                      );
                      var responseData = json.decode(response.body);
                      if (response.statusCode == 200 &&
                          responseData["description"] ==
                              quoteController.text.toString() &&
                          responseData["id"] >= 1) {
                        Navigator.pop(context);
                      } else {
                        ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
                          content: Text(
                              "Something went wrong while posting your quote!"),
                        ));
                      }
                    },
                    child: Text("Add Quote"))
              ]),
            ));
      }
    }

The implementation of Firebase on a Strapi server looks like this:

Conclusion

This tutorial taught us how to add Firebase cloud messaging to our Strapi server. In addition, we figured out how to extend collection functions to create relations and trigger push notifications. We then used a flutter application to test the different functionalities we added to our Strapi server. We got Firebase cloud messaging tokens through the application, made authenticated requests, and received push notifications from the Strapi Server. Flutter enabled us to test our notifications integration on different platforms.

You can view the source code from the following repositories:

  1. Flutter front-end

  2. Strapi back-end