DEV Community

Cover image for Create a real-time parcel tracking system in Flutter
Demola Malomo for Hackmamba

Posted on • Updated on

Create a real-time parcel tracking system in Flutter

Industries such as gaming, financial services, marketing, internet of things, and supply chain have revolutionized their products' digital experience by leveraging real-time technologies to deliver pieces of information to their users as quickly as it happens. These real-time experiences are an integral part of our favourite applications when processing messages, payments, and calls.

In this post, we will learn how to create a real-time parcel tracking system using Appwrite and Flutter.

Appwrite is a development platform that provides a powerful API and management console for building backend servers for web and mobile applications.

The GitHub repository can be found here.

Prerequisites

To fully grasp the concepts presented in this tutorial, the following requirements apply:

Getting started

We need to create a new Flutter project by navigating to the desired directory and running the command below in our terminal.

    flutter create parcel_tracker && cd parcel_tracker
Enter fullscreen mode Exit fullscreen mode

The command creates a Flutter project called parcel_tracker and navigates into the project directory.

We install the required dependency by navigating to the root directory and opening the pubspec.yaml file, and then add the Appwrite’s SDK to the dependency section.

    appwrite: ^4.0.2
Enter fullscreen mode Exit fullscreen mode

Install dependencies

P.S.: An editor like Visual Studio Code automatically installs the dependencies for us when we save the file. We might need to stop our project and run flutter pub get to install the dependency manually for other editors.

Creating a new Appwrite project

To create a new project, we need to startup our Appwrite instance on our machine and navigate to the specified hostname and port http://localhost:80. Next, we need to log in to our account or create an account if we don’t have one.

Appwrite running

We can learn more on how to set up Appwrite here.

On the console, click on the Create Project button, input flutter_appwrite as the name, and click Create.

Create project
Enter project name

Next, we need to create a database to save our notes. Navigate to the Database tab, click on Add Collection, input flutter_appwrite_col as the collection name, and then click on Create.

Navigate to Database Tab
Click on create project
Enter project name & create

Appwrite has an advanced yet flexible way to manage access to users, teams, or roles to access specific resources. We will modify the permission role:all to enable access from any application. Then click on Update to save changes.

modify permission

Add attributes

Attributes are fields that our database will possess. Navigate to the Attributes tab, click on Add Attributes, add a New URL Attribute for parcel_img and a New Enum Attribute for status fields, respectively, mark as required, and then click on Create.

select attribute and add fields
add field

For the status field, we need to add packed, shipped, in-transit, and delivered as elements to simulate the parcel tracking progress.

input status and click on add element to add it

Add sample data

Next, we can add sample data by navigating to the Documents tab, clicking on Add Document, inputting the required fields, and clicking on Create.

Add document
Input fields and create

Connecting Appwrite to Flutter

To add support for our Flutter app, navigate to the Home menu, click on the Add Platform button, and select New Flutter App.

Add Platform
Select New Flutter App

Depending on the device on which we are running our Flutter application, we can modify it as shown below.

iOS
To obtain our Bundle ID, we can navigate using the path below, open the project.pbxproj file, and search for PRODUCT_BUNDLE_IDENTIFIER.
ios > Runner.xcodeproj > project.pbxproj

iOS

Next, open the project directory on Xcode, open the Runner.xcworkspace folder in the app's iOS folder, select the Runner project in the Xcode project navigator, select Runner target in the main menu sidebar, and select iOS 11 in the deployment info’s target.

Change deployment target

Android
To get our package name, we can navigate using the path below, open the AndroidManifest.xml file, and copy the package value.
android > app > src > debug > AndroidManifest.xml

Android

Next, we need to modify the AndroidManifext.xml as shown below:

    <manifest xmlns:android="http://schemas.android.com/apk/res/android"
        package="com.example.note_app">
        <uses-permission android:name="android.permission.INTERNET"/>
        <application ...>
        <activity android:name="com.linusu.flutter_web_auth.CallbackActivity" android:exported="true">
          <intent-filter android:label="flutter_web_auth">
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.DEFAULT" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="appwrite-callback-[PROJECT_ID]" />
          </intent-filter>
        </activity>
      </application>
    </manifest>
Enter fullscreen mode Exit fullscreen mode

Then, we need to navigate to the lib directory and create a utils.dart file, and add the snippet below:

    class AppConstant {
      final String projectId = "REPLACE WITH YOUR PROJECT ID";
      final String endpoint = "REPLACE WITH YOUR ENPOINT";
      final String collectionId = "REPLACE WITH YOUR COLLECTION ID";
    }
Enter fullscreen mode Exit fullscreen mode

Navigate to the settings menu for the project and database to copy the Project ID, API Endpoint, and Collection ID.


For the endpoint property, we need to modify it to work with our system's local network address. We can adjust accordingly:

iOS
Navigate to the Network section, copy the IP address, and modify as shown below:

    class AppConstant {
      final String projectId = "REPLACE WITH YOUR PROJECT ID";
      final String endpoint = "http://192.168.1.195/v1";
      final String collectionId = "REPLACE WITH YOUR COLLECTION ID";
    }
Enter fullscreen mode Exit fullscreen mode

Android
We can connect our Android emulator to the system’s IP using the 10.0.2.2 IP address.

    class AppConstant {
      final String projectId = "REPLACE WITH YOUR PROJECT ID";
      final String endpoint = "http://10.0.2.2/v1";
      final String collectionId = "REPLACE WITH YOUR COLLECTION ID";
    }
Enter fullscreen mode Exit fullscreen mode

Building the parcel tracker

To get started, we need to create a model to convert the response sent from Appwrite to a Dart object. The model will also cater to JSON serialization. To do this, add the snippet below in the same utils.dart file:

    class AppConstant {
      //code goes here
    }

    class Parcel {
      String? $id;
      String parcel_img;
      String status;

      Parcel({this.$id, required this.parcel_img, required this.status});

      factory Parcel.fromJson(Map<dynamic, dynamic> json) {
        return Parcel(
            $id: json['\$id'],
            parcel_img: json['parcel_img'],
            status: json['status']);
      }

      Map<dynamic, dynamic> toJson() {
        return {'parcel_img': parcel_img, 'status': status};
      }
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Creates a Parcel class with required properties
  • Adds a constructor with unrequired and required parameters
  • Creates a fromJson and toJson method for JSON serialization

Next, we need to create a home.dart file inside the lib directory and update it by doing the following:

First, we need to import the required dependencies, create a Home view to display the application, and add variables required to perform real-time functionality.

    import 'package:appwrite/appwrite.dart';
    import 'package:flutter/material.dart';
    import 'package:parcel_tracker/utils.dart';

    class Home extends StatefulWidget {
      const Home({Key? key}) : super(key: key);
      @override
      State<Home> createState() => _HomeState();
    }

    class _HomeState extends State<Home> {
      List<Parcel>? items;
      bool _isLoading = false;
      bool _isError = false;
      Client client = Client();
      Database? db;
      RealtimeSubscription? realtimeSubscription;

      @override
      Widget build(BuildContext context) {
        //widget goes here
      }
    }
Enter fullscreen mode Exit fullscreen mode

Secondly, we need to use the initState method that configures the client and the db instances using the AppConstants defined earlier. The initState also contains a _loadParcel and _subcribe method. We will create these methods in the next step.

    //import goes here

    class Home extends StatefulWidget {
      const Home({Key? key}) : super(key: key);
      @override
      State<Home> createState() => _HomeState();
    }

    class _HomeState extends State<Home> {
      //variables goes here

      @override
      void initState() {
        super.initState();
        client
            .setEndpoint(AppConstant().endpoint)
            .setProject(AppConstant().projectId);
        db = Database(client);
        _loadParcel();
        _subscribe();
      }  

      @override
      Widget build(BuildContext context) {
        //widget goes here
      }
    }
Enter fullscreen mode Exit fullscreen mode

Thirdly, we need to create a _loadParcel method to get the list of documents in our Appwrite database using the listDocuments function and updating state accordingly.

    //import goes here

    class Home extends StatefulWidget {
      const Home({Key? key}) : super(key: key);
      @override
      State<Home> createState() => _HomeState();
    }

    class _HomeState extends State<Home> {
      //variables goes here

      @override
      void initState() {
        //initState code goes here
      }  

       _loadParcel() async {
        setState(() {
          _isLoading = true;
        });
        try {
          final data =
              await db?.listDocuments(collectionId: AppConstant().collectionId);
          setState(() {
            items = data?.documents
                .map((parcel) => Parcel.fromJson(parcel.data))
                .toList();
            _isLoading = false;
          });
        } catch (e) {
          setState(() {
            _isLoading = false;
            _isError = true;
          });
          throw Exception('Error getting list of parcel');
        }
      }

      @override
      Widget build(BuildContext context) {
        //widget goes here
      }
    }
Enter fullscreen mode Exit fullscreen mode

Fourthly, we need to create a _subscribe method for handling real-time functionality in the application.

    //import goes here

    class Home extends StatefulWidget {
      const Home({Key? key}) : super(key: key);
      @override
      State<Home> createState() => _HomeState();
    }

    class _HomeState extends State<Home> {
      //variables goes here

      @override
      void initState() {
        //initState code goes here
      }  

       _loadParcel() async {
        //code goes here
      }

      _subscribe() {
        final realtime = Realtime(client);
        String collectionID = AppConstant().collectionId;
        realtimeSubscription =
            realtime.subscribe(['collections.$collectionID.documents']);
        //listening to stream we can listen to
        realtimeSubscription!.stream.listen((e) {
          if (e.payload.isNotEmpty) {
            if (e.event == 'database.documents.update') {
              items!
                  .map((element) => element.status = e.payload['status'])
                  .toList();
              setState(() {});
            }
          }
        });
      }

      @override
      Widget build(BuildContext context) {
        //widget goes here
      }
    }
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Creates a realtime and collectionID variable that subscribes to the Appwrite event and the collection ID, respectively
  • Uses the realtimeSubscription variable to subscribe to the document with matching collectionID
  • Listens to the returned stream to check that it is not empty
  • Checks whether the returned event is an update action and updates the document based on the payload returned

Lastly, we need to create a _getStatusColor helper method to change the status background and update the widgets accordingly.

//import goes here

class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);
  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  //variables goes here

  @override
  void initState() {
    //initState code goes here
  }  

   _loadParcel() async {
    //code goes here
  }

  _subscribe() {
    //code goes here
  }

  _getStatusColor(String status) {
    switch (status.toLowerCase()) {
      case "packed":
        return 0xffAEAEB2;
      case "shipped":
        return 0xffF1CFA0;
      case "in-transit":
        return 0xffD9D9F4;
      case "delivered":
        return 0xff92EAA8;
      default:
        return 0xffAEAEB2;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Parcel Tracker"),
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _isError
              ? const Center(
                  child: Text(
                    'Error loading parcels',
                    style: TextStyle(
                      color: Colors.red,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                )
              : Padding(
                  padding: const EdgeInsets.all(30.0),
                  child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Row(
                          children: [
                            const Text(
                              "Order ID:",
                              style: TextStyle(
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                            SizedBox(width: 30.0),
                            Text(
                              items![0].$id!,
                              style: TextStyle(fontWeight: FontWeight.w700),
                            ),
                          ],
                        ),
                        const SizedBox(height: 15.0),
                        Row(
                          children: [
                            const Text(
                              "Status:",
                              style: TextStyle(
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                            const SizedBox(width: 30.0),
                            Container(
                              padding: const EdgeInsets.all(10.0),
                              decoration: BoxDecoration(
                                color: Color(_getStatusColor(items![0].status)),
                                borderRadius:
                                    const BorderRadius.all(Radius.circular(20)),
                              ),
                              child: Text(
                                items![0].status,
                                style: TextStyle(fontWeight: FontWeight.w700),
                              ),
                            ),
                          ],
                        ),
                        const SizedBox(height: 30.0),
                        Image.network(items![0].parcel_img),
                      ]),
                ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Complete utils.dart code:

class AppConstant {
  final String projectId = "YOUR PROJECTID GOES HERE";
  final String endpoint = "http://192.168.1.7/v1";
  final String collectionId = "YOUR COLLECTIONID GOES HERE";
}

class Parcel {
  String? $id;
  String parcel_img;
  String status;

  Parcel({this.$id, required this.parcel_img, required this.status});

  factory Parcel.fromJson(Map<dynamic, dynamic> json) {
    return Parcel(
        $id: json['\$id'],
        parcel_img: json['parcel_img'],
        status: json['status']);
  }

  Map<dynamic, dynamic> toJson() {
    return {'parcel_img': parcel_img, 'status': status};
  }
}
Enter fullscreen mode Exit fullscreen mode

Complete Home.dart code:

import 'package:appwrite/appwrite.dart';
import 'package:flutter/material.dart';
import 'package:parcel_tracker/utils.dart';

class Home extends StatefulWidget {
  const Home({Key? key}) : super(key: key);

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  List<Parcel>? items;
  bool _isLoading = false;
  bool _isError = false;
  Client client = Client();
  Database? db;
  RealtimeSubscription? realtimeSubscription;

  @override
  void initState() {
    super.initState();
    client
        .setEndpoint(AppConstant().endpoint)
        .setProject(AppConstant().projectId);

    db = Database(client);
    _loadParcel();
    _subscribe();
  }

  _loadParcel() async {
    setState(() {
      _isLoading = true;
    });
    try {
      final data =
          await db?.listDocuments(collectionId: AppConstant().collectionId);
      setState(() {
        items = data?.documents
            .map((parcel) => Parcel.fromJson(parcel.data))
            .toList();
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
        _isError = true;
      });
      throw Exception('Error getting list of parcel');
    }
  }

  _subscribe() {
    final realtime = Realtime(client);
    String collectionID = AppConstant().collectionId;
    realtimeSubscription =
        realtime.subscribe(['collections.$collectionID.documents']);

    //listening to stream we can listen to
    realtimeSubscription!.stream.listen((e) {
      if (e.payload.isNotEmpty) {
        if (e.event == 'database.documents.update') {
          items!
              .map((element) => element.status = e.payload['status'])
              .toList();
          setState(() {});
        }
      }
    });
  }

  _getStatusColor(String status) {
    switch (status.toLowerCase()) {
      case "packed":
        return 0xffAEAEB2;
      case "shipped":
        return 0xffF1CFA0;
      case "in-transit":
        return 0xffD9D9F4;
      case "delivered":
        return 0xff92EAA8;
      default:
        return 0xffAEAEB2;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Parcel Tracker"),
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _isError
              ? const Center(
                  child: Text(
                    'Error loading parcels',
                    style: TextStyle(
                      color: Colors.red,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                )
              : Padding(
                  padding: const EdgeInsets.all(30.0),
                  child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Row(
                          children: [
                            const Text(
                              "Order ID:",
                              style: TextStyle(
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                            SizedBox(width: 30.0),
                            Text(
                              items![0].$id!,
                              style: TextStyle(fontWeight: FontWeight.w700),
                            ),
                          ],
                        ),
                        const SizedBox(height: 15.0),
                        Row(
                          children: [
                            const Text(
                              "Status:",
                              style: TextStyle(
                                fontWeight: FontWeight.w500,
                              ),
                            ),
                            const SizedBox(width: 30.0),
                            Container(
                              padding: const EdgeInsets.all(10.0),
                              decoration: BoxDecoration(
                                color: Color(_getStatusColor(items![0].status)),
                                borderRadius:
                                    const BorderRadius.all(Radius.circular(20)),
                              ),
                              child: Text(
                                items![0].status,
                                style: TextStyle(fontWeight: FontWeight.w700),
                              ),
                            ),
                          ],
                        ),
                        const SizedBox(height: 30.0),
                        Image.network(items![0].parcel_img),
                      ]),
                ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to update the main.dart file to include the Home screen:

    import 'package:flutter/material.dart';
    import 'package:parcel_tracker/home.dart';

    void main() {
      runApp(const MyApp());
    }

    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const Home(),
        );
      }
    }
Enter fullscreen mode Exit fullscreen mode

Testing the application

Updating the document can be performed from any application with access to the project. However, we will be updating the app from the Appwrite console for this tutorial.

We can run the project using the command below:

    flutter run
Enter fullscreen mode Exit fullscreen mode

Upon running the project, we should see our application subscribed to events from Appwrite.

App subscribed to Appwrite

https://media.giphy.com/media/VPujztpDxQKslvbpKW/giphy.gif

P.S.: This demo is a base implementation to demonstrate Appwrite’s real-time capability. Full implementation will require the storage of multiple parcels and the propagation of events for each.

Conclusion

This post discussed how to create a real-time app using Flutter and Appwrite. The Appwrite platform ships with a robust SDK for building real-time applications.

These resources might be helpful:

Discussion (0)