Daily water in-take reminders using Cron Jobs
- Chima PreciousSoftware Engineer @Globe
Introduction
In Globe Release Notes for June, 2024, We made huge improvements to our Cron Jobs feature. With Cron Jobs, you can perform regular scheduled actions such as sending push notifications / reminders, report generation, and so on.
To demonstrate the usefulness of this feature, We will build a Daily water in-take reminder app purely in Dart and also use Cron Jobs feature on Globe to check and dispatch water in-take reminders. The libraries we’ll be using are listed below:
- Flutter - https://flutter.dev/
- Firebase Auth, Messaging, Admin SDK - https://firebase.flutter.dev/docs/overview
- Shelf - https://pub.dev/packages/shelf
Let’s Build 🛠️
Frontend (Flutter)
Start by creating a new Flutter project and setting up Firebase in your project.
flutter create frontend
After project creation successfully, be sure to add the following packages to your pubspec.yaml
.
firebase_auth: ^5.0.0
firebase_core: ^3.0.0
firebase_messaging: ^15.0.0
cloud_firestore: ^5.0.0
timeago: ^3.6.1
flutter_local_notifications: ^17.1.2
Next, set up Firebase within the project by running flutterfire configure
.
Setup Firebase & Push Notifications
Modify your main.dart
file to have this code. This will initialize firebase & setup support for Firebase Push notifications.
import 'dart:async';
import 'dart:io';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:frontend/firebase_options.dart';
final waterIntakeCollection =
FirebaseFirestore.instance.collection('water_intakes');
final userCollection = FirebaseFirestore.instance.collection('users');
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await setupNotifications();
runApp(const MainApp());
}
Next, let’s implement our setupNotifications
function.
Future<void> setupNotifications() async {
await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
alert: true, // Required to display a heads up notification
badge: true,
sound: true,
);
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'high_importance_channel', // id
'High Importance Notifications', // title
description: 'This channel is used for important notifications.',
importance: Importance.max,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>()
?.createNotificationChannel(channel);
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final notification = message.notification;
final android = message.notification?.android;
if (notification == null || android == null) return;
// If `onMessage` is triggered with a notification, construct our own
// local notification to show to users using the created channel.
flutterLocalNotificationsPlugin.show(
notification.hashCode,
notification.title,
notification.body,
NotificationDetails(
android: AndroidNotificationDetails(
channel.id,
channel.name,
channelDescription: channel.description,
icon: "ic_launcher",
),
),
);
});
}
Be sure to add the code below to your AndroidManifest.xml
file in android
directory.
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" />
Setup Authentication (Anonymous) & Register Device Token
We will need each device messaging token to be able to notify users. To make this work, we’ll authenticate every user anonymously and store their details on Firestore.
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
bool _loading = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
init();
});
}
void init() async {
setState(() => _loading = true);
// Request notification permissions
final result = await FirebaseMessaging.instance.requestPermission();
if (result.authorizationStatus != AuthorizationStatus.authorized) {
exit(0);
}
// Ensure we're authenticated
var user = FirebaseAuth.instance.currentUser;
user ??= (await FirebaseAuth.instance.signInAnonymously()).user;
// Get messaging token & update user data
final token = await FirebaseMessaging.instance.getToken();
await userCollection.doc(user!.uid).set({
'fcm_token': token,
'updated_at': DateTime.timestamp().toIso8601String(),
});
setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const HomePage(),
theme: ThemeData.dark(),
builder: (_, child) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
return child!;
},
);
}
}
Implement HomePage
For our home page, we’ll need two important functions. logWaterIntake
and lastIntake
.
logWaterIntake
will be called when user taps button to record water in-take.
bool _loading = false;
void logWaterIntake() async {
setState(() => _loading = true);
await waterIntakeCollection.add({
"timestamp": DateTime.timestamp().toIso8601String(),
"user_uid": user.uid,
});
setState(() => _loading = false);
}
lastIntake
will return the last water in-take recorded
Future<DateTime?> get lastIntake async {
final result = await waterIntakeCollection
.where('user_uid', isEqualTo: user.uid)
.orderBy('timestamp')
.limitToLast(1)
.get();
final docs = result.docs;
if (docs.isEmpty) return null;
return DateTime.parse(docs.first.data()['timestamp']);
}
Putting all this together, we’ll have this.
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:frontend/firebase_options.dart';
import 'package:timeago/timeago.dart' as timeago;
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
User get user => FirebaseAuth.instance.currentUser!;
bool _loading = false;
void logWaterIntake(); /// add implementation here
Future<DateTime?> get lastIntake; /// add implementation here
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: FutureBuilder<DateTime?>(
future: lastIntake,
builder: (context, snapshot) {
if (_loading ||
snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
final lastWaterIntake = snapshot.data;
final timeSinceLastIntake = lastWaterIntake == null
? null
: DateTime.now().difference(lastWaterIntake).inHours;
final shouldLogIntake =
timeSinceLastIntake != null && timeSinceLastIntake >= 2;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!shouldLogIntake && lastWaterIntake != null) ...[
Text.rich(
TextSpan(
text: 'Last Intake:',
children: [
TextSpan(text: timeago.format(lastWaterIntake))
],
),
),
const SizedBox(height: 20),
const Text(
'You are doing well\nYou will be notified for your next intake.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w300,
),
),
] else
OutlinedButton(
onPressed: logWaterIntake,
child: const Text(
'Log Water Intake',
style: TextStyle(fontSize: 15),
),
)
],
);
}),
),
);
}
}
When you run the app, you’re presented with the button to Log Water Intake
. When tapped, you then see the screen on the right. Now let’s go work on the backend to remind us, in-case we forgot to drink water.
Backend (Shelf)
Create a new dart backend project using the command below.
dart create server -t server-shelf
After project creation successfully, add the following packages to your pubspec.yaml
.
dart_firebase_admin: ^0.3.1
dotenv: ^4.2.0
Setup Firebase Admin for Server
To generate a private key for your service account:
- In the Firebase console, open Settings > Service Accounts
- Click Generate New Private Key, then confirm by clicking Generate Key
- Securely store the JSON file containing the key.
Create a
.env
in the root of yourserver
directory and add theFIREBASE_PROJECT_ID=<your-firebase-project-id>
environment variable. We will only need this during local development.
Create a firebase_admin.dart
file in the lib
directory and add this code to initialize Firebase Admin on the server.
import 'package:dart_firebase_admin/dart_firebase_admin.dart';
import 'package:dart_firebase_admin/firestore.dart';
import 'package:dart_firebase_admin/messaging.dart';
import 'package:dotenv/dotenv.dart';
final env = DotEnv(includePlatformEnvironment: true, quiet: true)..load();
final userCollection = firestore.collection('users');
final waterIntakeCollection = firestore.collection('water_intakes');
late Firestore firestore;
late Messaging messaging;
void initFirebase() {
final cred = Credential.fromApplicationDefaultCredentials();
if (cred.serviceAccountCredentials == null) {
throw Exception(
'Please provide GOOGLE_APPLICATION_CREDENTIALS variable in environment.',
);
}
final projectId = env['FIREBASE_PROJECT_ID'];
if (projectId == null) {
throw Exception('Please provide FIREBASE_PROJECT_ID in environment');
}
final admin = FirebaseAdminApp.initializeApp(projectId, cred);
firestore = Firestore(admin);
messaging = Messaging(admin);
}
Inside your main
entry-point in bin/server.dart
file, make sure to call initFirebase()
.
import 'package:server/firebase_admin.dart';
void main(List<String> args) async {
initFirebase();
///...
}
Add Cron Route & Logic
We’ll need to add an HTTP-Route that will be periodically called by our Cron Job on Globe. This route will check last water in-take for all our users and notify users where necessary.
- Add Route
POST: /tasks/notify-water-intake
// Configure routes.
final _router = Router()
..post('/tasks/notify-water-intake', _notifyWaterIntake);
- Next, let’s implement our
_notifyWaterIntake
function to respond to the route.
To make sure we’re only notifying active users, we’ll limit our query to users with app activity within the last three days.
FutureOr<Response> _notifyWaterIntake(Request request) async {
final threeDaysAgo = DateTime.timestamp().subtract(const Duration(days: 3));
// Get only users who opened app within last 3 days
final userQuery = await userCollection
.where(
'updated_at',
WhereFilter.greaterThanOrEqual,
threeDaysAgo.toIso8601String(),
)
.orderBy('updated_at')
.get();
final userDocs = userQuery.docs;
if (userDocs.isEmpty) {
return Response.ok('Nothing to do here');
}
///... we'll implement next part soon
}
In the next steps, we’ll check last water in-take for each of these users; we’ll notify users with last in-take older than 2 hours ago.
final users =
userDocs.map((e) => {...e.data(), 'id': e.id}).toList(growable: false);
final pendingMessages = <TokenMessage>[];
/// For each of these users, check their last water intake and notify where necessary.
for (final user in users) {
final result = await waterIntakeCollection
.where('user_uid', WhereFilter.equal, user['id'])
.orderBy('timestamp')
.limitToLast(1)
.get();
if (result.docs.isNotEmpty) {
final lastIntakeDate =
DateTime.parse(result.docs.first.data()['timestamp'].toString());
final nextIntakeDue =
DateTime.timestamp().difference(lastIntakeDate).inHours >= 2;
if (!nextIntakeDue) continue;
}
final message = TokenMessage(
token: user['fcm_token'].toString(),
notification: Notification(
title: 'Drink water 💦',
body: "It's time to drink water again 🥛, stay hydrated",
),
);
pendingMessages.add(message);
}
Voila 🎉, we’re almost getting done, stay with me.
if (pendingMessages.isEmpty) {
return Response.ok('Nothing to do here');
}
// Send messages
await messaging.sendEach(pendingMessages);
return Response.ok('${pendingMessages.length} messages sent');
We’re finally done with implementation for our backend server which will respond to our Cron Job on Globe.
Setup Cron Job
Create globe.yaml
in the root of server
directory and add the code below.
crons:
- id: check_and_notify_water_intake
schedule: '0 */2 * * *'
path: '/tasks/notify-water-intake'
Let’s unpack the pieces we just added.
-
id
: the name of our cron schedule. -
schedule
: specifies the frequency of our schedule. -
path
: the endpoint on our backend which will be called by the cron-job.
Our cron will run every 2 hours
and it’ll call the /tasks/notify-water-intake
route.
Let’s Deploy 🚀
Deployment is super easy on Globe. We have Github automations and a CLI to make this as easy as possible. If you don’t already have CLI installed, you can follow this guide. Globe CLI
Cron Jobs only work in production as of now. We’ll need to create a production deployment to get things working.
In the root of your server
directory, run the command below.
globe deploy --prod
View Cron Dashboard
On your Globe Dashboard, you can view Cron Jobs Dashboard by clicking on the Cron Jobs Tab. As you can see below, we have our check_and_notify_water_intake
job visible on the dashboard but it failed with status code 503
. Clicking on the button on the far right will show all invocations of this job.
From the error log below, we need to provide the GOOGLE_APPLICATION_CREDENTIALS
environment variable.
Provide GOOGLE_APPLICATION_CREDENTIALS
in environment variable.
Copy the contents of your Firebase serviceAccount.json
file we downloaded earlier, and add it as an Environment Variable in Project Settings on Globe.
Once completed, let’s re-deploy our project to use our newly added environment variables.
globe deploy --prod
And voila, we should see a push notification, reminding us to drink water every 2 hours, just in-case we forgot to. Cheers 👏
Source Code
You can find the source code for this blog post on my Github Repo: https://github.com/codekeyz/stay-hydrated. Be sure to follow me for more cool stuffs like this.