Building a recipe app with Transloadit & Flutter pt. 2
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.
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!
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.