This is the second installment of my blog series on creating a recipe app with Transloadit and Flutter. Take a look at part one if you haven't already.

The Transloadit logo, with a chef hat on top of Botty.

In this blog, we'll add functionality to the app we constructed in the previous section. We'll be using Firebase, so follow these install instructions and ensure your project's running on, at a minimum, the pay-as-you-go Blaze plan. This is used to access Cloud Functions that power the API we're building.

Creating an API

We're going to take a step back from our app for a second and start work on our Firebase Cloud Functions API.

Create a new folder for our API and run the following commands, making sure to select the TypeScript option.

nvm install 14
nvm use 14
npm install firebase-tools -g
firebase login
firebase init functions
cd functions

Next, inside of /functions/src, create http.ts. This is where we'll be receiving our HTTP requests. Start by initializing your app.

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'

admin.initializeApp()

Afterward, let's create a function to handle our POST request and a few query parameters.

export const receivePOST = functions.https.onRequest((request, response) => {
  const token = String(request.query.token)
  const name = String(request.query.name)

  const payload = {
    notification: {
      title: `Your ${name} is ready!`,
      body: 'Tap here to check it out!',
    },
    data: {
      click_action: 'FLUTTER_NOTIFICATION_CLICK',
      sound: 'default',
      status: 'done',
      screen: '/receipt',
    },
  }

  const options = {
    priority: 'high',
    timeToLive: 60 * 60 * 24,
  }

  admin
    .messaging()
    .sendToDevice(token, payload, options)
    .catch((error) => {
      response.status(500)
      console.log(error)
    })

  response.send('Transloadit!')
  response.status(200)
})

Our first parameter, token, contains the device token that requested the Assembly. We use this to send a notification to that device. The name parameter is set to the name of the food the user selected, which gets displayed in the notification part of the payload. Next, the data section contains information not visible to the user.

Firebase's Cloud Messaging service then sends a notification to the user's device. We give our notification high priority and grant the user a maximum of one day to receive it before the notification is discarded.

Now, we need to export this function from index.ts.

export { receivePOST } from './http'

Then from our console deploy your Firebase Cloud Function.

firebase deploy --only functions

Preparing our app to receive notifications

Notifications on mobile can sometimes be tricky, mainly depending on whether the notification is in the foreground or background. Therefore we need to cover all bases. We'll create /utils/notifications.dart, and inside it, make the Notifications class.

import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

import '../main.dart';


class Notifications {
  static Future<void> initializeNotifications() async {
    FirebaseMessaging messaging = FirebaseMessaging.instance;

    await messaging.getToken().then((t) {
      token = t!;
    });
  }
}

Our first function obtains the device's Firebase Cloud Messenger token, which we later pass to our API to send a notification to this specific device.

static Future<void> listenForNotification(BuildContext context) async {
  // Received a notification while app is in foreground
  FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
    navigateToScreen(context, message);
    showNotification(message);
  });
  // Opened a notification while app is in background
  FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) async {
    navigateToScreen(context, message);
  });
}

static navigateToScreen(BuildContext context, RemoteMessage message) {
  Navigator.pushNamed(context, '${message.data["screen"]}');
}

We set up a listener for our notifications within the same class. This handles both the app's foreground and background. We can then direct our user to the screen specified in the data section of the payload, by passing the message to the navigateToScreen function.

Next, we send a notification using the local notifications package if we receive an FCM message while the app is in the foreground.

static Future<void> showNotification(RemoteMessage message) async {
  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'high_importance_channel', // id
    'Food Notification Channel', // title
    'This channel is used for telling you when your food is done.', // description
    importance: Importance.max,
  );

  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
      FlutterLocalNotificationsPlugin();

  final AndroidInitializationSettings initializationSettingsAndroid =
      AndroidInitializationSettings('app_icon');

  final InitializationSettings initializationSettings =
      InitializationSettings(
          android: initializationSettingsAndroid);

  await flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation<
          AndroidFlutterLocalNotificationsPlugin>()
      ?.createNotificationChannel(channel);

  await flutterLocalNotificationsPlugin.initialize(initializationSettings);
  RemoteNotification? notification = message.notification;
  AndroidNotification? android = message.notification?.android;

  if (notification != null && android != null) {
    await flutterLocalNotificationsPlugin.show(
      notification.hashCode,
      notification.title,
      notification.body,
      NotificationDetails(
        android: AndroidNotificationDetails(
          channel.id,
          channel.name,
          channel.description,
          importance: channel.importance,
        ),
      ),
    );
  }
}

Without getting ahead of ourselves, head to /android/app/src/main/AndroidManifest.xml and update it's meta-data tag, inside of the application header.

<application
        android:label="transloadit_recipes"
        android:icon="@mipmap/ic_launcher">
        <meta-data
          android:name="com.google.firebase.messaging.default_notification_channel_id"
          android:value="high_importance_channel" />
        ...

And add an app icon to android/app/src/main/res/drawable with the name app_icon.png.

Sending files to Transloadit

Currently, no requests are sent to our API to trigger a notification, so let's fix that!

The first thing we'll need to do is make a client. Create a new function called initializeSecrets within main.dart. We'll use Firebase's Remote Config to store our keys.

Future<void> initializeSecrets() async {
  //Handle Secrets
  RemoteConfig remoteConfig = RemoteConfig.instance;
  await remoteConfig.fetchAndActivate();

  String key = remoteConfig.getValue('key').asString();
  String secret = remoteConfig.getValue('secret').asString();
  client = TransloaditClient(authKey: key, authSecret: secret);
}

Then modify the main function.

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  await initializeSecrets();
  await Notifications.initializeNotifications();

  runApp(MyApp());
}

Inside the Firebase console, navigate to 'Remote Config' and add two secret and key parameters. Set these values to match your Transloadit credentials.

Create utils/transloadit.dart, and inside it create the Transloadit class with two utility functions: _imageToFile, and sendToTransloadit.

import 'dart:io';

import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:transloadit/transloadit.dart';
import '../defs/food.dart';
import '../defs/response.dart';

import '../main.dart';
import 'food_list.dart';

class Transloadit {
  static Future<File> _imageToFile(
      {required String path, required String name}) async {
    var bytes = await rootBundle.load(path);
    String tempPath = (await getTemporaryDirectory()).path;
    File file = File('$tempPath/$name.png');
    await file.writeAsBytes(
        bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes));
    return file;
  }

  static Future<void> sendToTransloadit({
    required List<Food> foodList,
    required Function() onCompleteUpload,
  }) async {
    isLoading.value = true;
    FoodList.foodList.forEach(
      (food) async {
        TransloaditAssembly assembly = client.newAssembly(
          params: {
            'notify_url':
                'https://us-central1-[YOUR_FIREBASE_APP_NAME].cloudfunctions.net/receivePOST?name=${food.title}&token=$token',
          },
        );
        assembly.addFile(
          file: await _imageToFile(
            path: food.image,
            name: food.title,
          ),
        );
        assembly.addStep(
          food.title,
          food.subtitle,
          Map<String, dynamic>.from(food.ingredients),
        );

        Future<TransloaditResponse> future = assembly.createAssembly(
          onComplete: onCompleteUpload,
        );
        //Adds the file to the global results list
        TransloaditResponse response = await future;
        results.value = List.from(results.value)
          ..add(Response(response: response.data, name: food.title));
      },
    );
  }
}

The functions above will create a local copy of an image inside our app and an Assembly using the Steps from the Food object.

Note how we include the notify_url in the parameters. This contains the URL to our Cloud Function. Transloadit will send a POST request to our API when our Assembly is finished, triggering a notification for the user.

Testing

That should be all our code finalized, but now it's time to put it through its paces. Let's make some delectable "Watermarked Salad" and press the cook button!

Demo

Conclusion

We've made it! It's been a long road, but we're finally here! I hope you agree that with this approach, the options for improving your app are practically unlimited. Feel free to play around with the project and see where it leads you.

If you'd like to, you can check the project out on GitHub.