Daily water in-take reminders using Cron Jobs

Jun 14, 2024
10 min read
  • Chima Precious
    Chima PreciousSoftware Engineer @Globe
Stay hydrated with Cron Jobs

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:

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.

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.

Android Emulator running Frontend Water In-take Logged

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 your server directory and add the FIREBASE_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
Globe Deploy

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.

Cron Dashboard

From the error log below, we need to provide the GOOGLE_APPLICATION_CREDENTIALS environment variable.

Cron Job Error

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.

Environment Variable

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 👏

Push Notification

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.

Share

Related Posts