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:
- Basic understanding of Dart and Flutter
- Flutter SDK installed
- Xcode (with developer account for Mac users)
- Either IOS Simulator, Android Studio, or Chrome web browser to run our application
- Docker installation
- An Appwrite instance; check out this article on how to set up an instance locally or one-click install on DigitalOcean
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
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
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.
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.
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.
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.
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.
For the status field, we need to add packed
, shipped
, in-transit
, and delivered
as elements to simulate the parcel tracking progress.
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.
parcel_img | status |
---|---|
https://res.cloudinary.com/dtgbzmpca/image/upload/v1652648108/parcel.png | packed |
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.
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
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.
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
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>
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";
}
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";
}
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";
}
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};
}
}
The snippet above does the following:
- Creates a
Parcel
class with required properties - Adds a constructor with unrequired and required parameters
- Creates a
fromJson
andtoJson
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
}
}
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
}
}
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
}
}
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
}
}
The snippet above does the following:
- Creates a
realtime
andcollectionID
variable that subscribes to the Appwrite event and the collection ID, respectively - Uses the
realtimeSubscription
variable to subscribe to the document with matchingcollectionID
- 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),
]),
),
);
}
}
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};
}
}
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),
]),
),
);
}
}
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(),
);
}
}
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
Upon running the project, we should see our application subscribed to events from 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: