Use Appwrite and Flutter to build a serverless mobile wallet service

Demola Malomo - Feb 22 '23 - - Dev Community

Mobile wallets are virtual/digital wallets that store information from loyalty cards, coupons, credit cards, and debit cards on smartphones or tablets. They offer a convenient way for users to make online and in-store payments.

In this post, we will learn how to use Appwrite’s database features to build a mobile wallet functionality in a Flutter application. The project’s GitHub repository can be found here.

Prerequisites

To fully grasp the concepts presented in this tutorial, the following are required:

Getting started

To get started, we need to clone the project by navigating to the desired directory and running the command below:

git clone https://github.com/Mr-Malomz/mobile_wallet.git
Enter fullscreen mode Exit fullscreen mode

Running the project

First, we need to install the project dependencies by running the command below:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

Then, run the project using the command below:

flutter run
Enter fullscreen mode Exit fullscreen mode

The command above will run the application on the selected device.

Home screen
List of transactions

Set up mobile wallet service on Appwrite

To get started, we need to log into our Appwrite console, click the Create project button, input mobile_wallet as the name, and then click Create.

Create project

Create a database, collection, attributes, and add sample data
With our project created, we can set up our application database. First, navigate to the Database tab, click the Create database button, input wallet as the name, and then click Create.

Create database

Secondly, we need to create two collections for storing user details and the transactions performed on the wallet. To do this, click the Create collection button, input users as the name, and then click Create. Following the same approach, we must create a transactions collection.

Users and transactions collection

Thirdly, we need to create attributes to represent our database fields. To do this, we need to navigate to the Attributes tab and create attributes for each of the collections, as shown below:

For the users collection:

Attribute key Attribute type Size Required
name string 250 YES
balance integer 5000 YES

Create attributes
Users collection attributes

We also need to update our collection permission to manage them accordingly. To do this, we need to navigate to the Settings tab, scroll to the Update Permissions section, select Any, mark accordingly, and then click Update.

Select any
Mark permission

For the transactions collection:

Attribute key Attribute type Size Required
userId string 250 YES
name string 250 YES
paymentMethod string 250 YES
amount integer 5000 YES

Create attributes
Transactions collection attributes

Following the same steps above, we also need to update the collection permission and update.

Mark permission

Lastly, we need to add sample data to simulate our wallet data. To do this, we need to navigate to the Document tab, click the Create document button and add data for each of the collections as shown below:

For users document:

name balance
John Travolta 1200

Create data
Created data

On creating the document, we also need to copy the document ID, as it will serve as a reference to the user when creating transactions.

Copy document ID

For the transactions document:

userId name paymentMethod amount
COPIED USER ID Groceries internet payment 10
COPIED USER ID Call subscription NFT 15

Create data
Created data

Add an index to the Transactions collection
Databases use indexes to improve the speed of data retrieval. To add an index to the transactions collection, navigate to the Indexes tab, click Create index button, input userId as the index key, select userId as the attribute and DESC as the order, and then click Create.

Add platform support
To add support for our Flutter app, navigate to the Home menu and click the Flutter App button.

Add platform

Depending on the device being used to run the Flutter application, we can modify it as shown below.

iOS
To obtain our Bundle ID, we can navigate using the path below:
ios > Runner.xcodeproj > project.pbxproj

Open the project.pbxproj file and search for PRODUCT_BUNDLE_IDENTIFIER.

Select new Flutter app

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 the Runner target in the primary menu sidebar, and then select iOS 11 in the deployment info’s target.

Change deployment target

Android
To get our package name, we can navigate to an XML file using the following path:
android > app > src > debug > AndroidManifest.xml

Open the AndroidManifest.xml file and copy the package value.

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.mobile_wallet">
    <uses-permission android:name="android.permission.INTERNET"/>
    <application ...>
    <activity android:name="com.linusu.mobile_wallet.CallbackActivity" android:exported="true">
      <intent-filter android:label="mobile_wallet">
        <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

We must also replace the highlighted [PROJECT_ID] with our actual Appwrite project ID.

Building the mobile wallet service

With all that done, let’s build the mobile wallet service. First, we need to create models to convert the response sent from Appwrite to a Dart object. The models will also cater to JSON serialization. To do this, we need to create a utils.dart file in the lib folder and add the snippet below:

class AppConstant {
  final String databaseId = "REPLACE WITH DATABASE ID";
  final String projectId = "REPLACE WITH PROJECT ID";
  final String userCollectionId = "REPLACE WITH USERS COLLECTION ID";
  final String transactionCollectionId = "REPLACE WITH TRANSACTIONS COLLECTION ID";
  final String userId = "REPLACE WITH SAMPLE ID";
  final String endpoint = "ENDPOINT";
}

class User {
  String? $id;
  String name;
  int balance;

  User({this.$id, required this.name, required this.balance});

  Map<dynamic, dynamic> toJson() {
    return {"name": name, "balance": balance};
  }

  factory User.fromJson(Map<dynamic, dynamic> json) {
    return User(
        $id: json['\$id'], name: json['name'], balance: json['balance']);
  }
}

class Transaction {
  String? $id;
  String userId;
  String name;
  String paymentMethod;
  int amount;

  Transaction({
    this.$id,
    required this.userId,
    required this.name,
    required this.paymentMethod,
    required this.amount,
  });

  Map<dynamic, dynamic> toJson() {
    return {
      "userId": userId,
      "name": name,
      "paymentMethod": paymentMethod,
      "amount": amount
    };
  }

  factory Transaction.fromJson(Map<dynamic, dynamic> json) {
    return Transaction(
        userId: json['userId'],
        name: json['name'],
        paymentMethod: json['paymentMethod'],
        amount: json['amount']);
  }
}
Enter fullscreen mode Exit fullscreen mode

We’ll need to modify the endpoint property so that it can work with our system's local network address. We can adjust accordingly:

iOS
Navigate to the Network section and copy the IP address.

IP address

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

final String endpoint = "http://10.0.2.2/v1";
Enter fullscreen mode Exit fullscreen mode

Note: We can get the databaseId, projectId, collectionIds, functionId, and userId by navigating through the Appwrite console.

Next, we must create a service file to separate the application core logic from the UI. To do this, create a wallet_service.dart file inside the lib directory. Then add the snippet below:

import 'package:appwrite/appwrite.dart';
import 'package:mobile_wallet/utils.dart';

class WalletService {
  Client _client = Client();
  Databases? _db;

  WalletService() {
    _init();
  }

  //initialize the application
  _init() async {
    _client
        .setEndpoint(AppConstant().endpoint)
        .setProject(AppConstant().projectId);
    _db = Databases(_client);

    //get current session
    Account account = Account(_client);
    try {
      await account.get();
    } on AppwriteException catch (e) {
      if (e.code == 401) {
        account
            .createAnonymousSession()
            .then((value) => value)
            .catchError((e) => e);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Imports the required dependencies
  • Creates a WalletService class with _client, _db properties to connect to the Appwrite instance and the database
  • Creates an _init method that configures Appwrite using the properties and also conditionally creates an anonymous user to access the Appwrite database

Lastly, we need to modify the WalletService class by adding getUserDetails, updateUserBalance, createTransaction, and getTransactions methods that use the _db property to manage wallet operations accordingly.

//imports go here

class WalletService {
  Client _client = Client();
  Databases? _db;

  WalletService() {
    _init();
  }

  //initialize the application
  _init() async {
    //init code goes here
  }

  Future<User> getUserDetails() async {
    try {
      var data = await _db?.getDocument(
          databaseId: AppConstant().databaseId,
          collectionId: AppConstant().userCollectionId,
          documentId: AppConstant().userId);
      var user = data?.convertTo((doc) => User.fromJson(doc));
      return user!;
    } catch (e) {
      throw Exception('Error getting user details');
    }
  }

  Future updateUserBalance(
    String userId,
    String name,
    int balance,
    int amount,
  ) async {
    int newBalance = balance - amount;
    try {
      User updatedUserBalance = User(name: name, balance: newBalance);
      var result = await _db?.updateDocument(
          databaseId: AppConstant().databaseId,
          collectionId: AppConstant().userCollectionId,
          documentId: userId,
          data: updatedUserBalance.toJson());
      return result;
    } catch (e) {
      throw Exception('Error updating user balance!');
    }
  }

  Future createTransaction(String userId, int amount) async {
    try {
      Transaction updatedTransaction = Transaction(
        userId: userId,
        name: "Stamp duty",
        paymentMethod: "internet payment",
        amount: amount,
      );
      var data = await _db?.createDocument(
        databaseId: AppConstant().databaseId,
        collectionId: AppConstant().transactionCollectionId,
        documentId: ID.unique(),
        data: updatedTransaction.toJson(),
      );
      return data;
    } catch (e) {
      throw Exception('Error creating transaction!');
    }
  }

  Future<List<Transaction>> getTransactions() async {
    try {
      var data = await _db?.listDocuments(
        databaseId: AppConstant().databaseId,
        collectionId: AppConstant().transactionCollectionId,
        queries: [Query.orderDesc('userId')],
      );
      var transactionList = data?.documents
          .map((transaction) => Transaction.fromJson(transaction.data))
          .toList();
      return transactionList!;
    } catch (e) {
      throw Exception('Error getting list of transactions!');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

P.S.: In the createTransaction method, we hard-coded transaction details for simulation purposes.

Consuming the service

With that done, we can start using the service to perform the required operation.

Get users’ details and create a transaction
To get started, we need to modify the home.dart file in the screens directory and update it by doing the following:

First, we need to import the required dependencies and create methods to get user details and create a transaction:

import 'package:flutter/material.dart';
import 'package:mobile_wallet/screens/transactions.dart';
import 'package:mobile_wallet/utils.dart';
import 'package:mobile_wallet/wallet_service.dart';

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

class _HomeState extends State<Home> {
  late User user;
  bool _isLoading = false;
  bool _isTransacting = false;
  bool _isError = false;

  @override
  void initState() {
    _getUserDetails();
    super.initState();
  }

  _getUserDetails() {
    setState(() {
      _isLoading = true;
    });
    WalletService().getUserDetails().then((value) {
      setState(() {
        user = value;
        _isLoading = false;
      });
    }).catchError((e) {
      setState(() {
        _isLoading = false;
        _isError = true;
      });
    });
  }

  _createTransaction(String userId, String name, int balance) {
    setState(() {
      _isTransacting = true;
    });
    int amount = 20;
    WalletService().createTransaction(userId, amount).then((value) {
      WalletService()
          .updateUserBalance(userId, name, balance, amount)
          .then((value) {
        //update balance in app
        int newBalance = user.balance - amount;
        setState(() {
          _isTransacting = false;
          user.balance = newBalance;
        });
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Transaction created successfully!')),
        );
      }).catchError((e) {
        setState(() {
          _isTransacting = false;
        });
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Error creating transaction!')),
        );
      });
    }).catchError((e) {
      setState(() {
        _isTransacting = false;
      });
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Error creating transaction!')),
      );
    });
  }

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

The snippet above does the following:

  • Lines 1-4: Import the required dependencies
  • Line 13-16: Create the user, _isLoading, _isTransacting, and _isError properties to manage application state
  • Lines 18-39: Create a _getUserDetails method to get the user details using the WalletService().getUserDetails service, set states accordingly, and use the initState method to call the _getUserDetails
  • Lines 41-75: Create a _createTransaction method to simulate a transaction using the WalletService().createTransaction service, and on successful creation, uses the WalletService().updateUserBalance service to update the wallet balance

Lastly, we need to modify the UI to conditionally show the users' details and simulate a transaction using the methods and states created above.

//import goes here

class Home extends StatefulWidget {
  //code goes here
}

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

  @override
  void initState() {
    _getUserDetails();
    super.initState();
  }

  _getUserDetails() {
    //code goes here
  }

  _createTransaction(String userId, String name, int balance) {
    //code goes here
  }

  @override
  Widget build(BuildContext context) {
    return _isLoading
        ? const Center(
            child: CircularProgressIndicator(
            color: Colors.blue,
          ))
        : _isError
            ? const Center(
                child: Text(
                  'Error getting users details',
                  style: TextStyle(
                    color: Colors.red,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              )
            : Scaffold(
                backgroundColor: Colors.white,
                body: Center(
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      Image.asset('images/wallet.jpg'),
                      const SizedBox(height: 40.0),
                      const Text(
                        'WALLET BALANCE',
                        style: TextStyle(
                            fontSize: 12.0, fontWeight: FontWeight.w500),
                      ),
                      const SizedBox(height: 5.0),
                      Text(
                        '\$ ${user.balance}',
                        style: TextStyle(
                            fontSize: 32.0, fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 40.0),
                      Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 10.0),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            OutlinedButton(
                              onPressed: () {
                                Navigator.push(
                                  context,
                                  MaterialPageRoute(
                                      builder: (context) =>
                                          const Transactions()),
                                );
                              },
                              child: Text(
                                'View transactions',
                                style: TextStyle(
                                    fontSize: 12.0,
                                    fontWeight: FontWeight.bold,
                                    color: Colors.black),
                              ),
                              style: ButtonStyle(
                                shape: MaterialStateProperty.all(
                                  RoundedRectangleBorder(
                                    borderRadius: BorderRadius.circular(5.0),
                                  ),
                                ),
                                side: MaterialStateProperty.all(
                                  const BorderSide(
                                      color: Colors.black,
                                      width: 1.5,
                                      style: BorderStyle.solid),
                                ),
                              ),
                            ),
                            const SizedBox(width: 20.0),
                            TextButton(
                              onPressed: _isTransacting
                                  ? null
                                  : () {
                                      _createTransaction(
                                        user.$id.toString(),
                                        user.name,
                                        user.balance,
                                      );
                                    },
                              child: Text(
                                'Spend from wallet',
                                style: TextStyle(
                                    fontSize: 12.0,
                                    fontWeight: FontWeight.bold,
                                    color: Colors.white),
                              ),
                              style: ButtonStyle(
                                  backgroundColor:
                                      MaterialStateProperty.all(Colors.black),
                                  side: MaterialStateProperty.all(
                                    const BorderSide(
                                        color: Colors.black,
                                        width: 1.5,
                                        style: BorderStyle.solid),
                                  ),
                                  padding:
                                      MaterialStateProperty.all<EdgeInsets>(
                                    const EdgeInsets.symmetric(
                                        horizontal: 15.0),
                                  )),
                            ),
                          ],
                        ),
                      )
                    ],
                  ),
                ),
              );
  }
}
Enter fullscreen mode Exit fullscreen mode

Get the list of transactions
To obtain the list of transactions, we need to modify the transactions.dart file in the same screens directory as shown below:

import 'package:flutter/material.dart';
import 'package:mobile_wallet/utils.dart';
import 'package:mobile_wallet/wallet_service.dart';

class Transactions extends StatefulWidget {
  const Transactions({super.key});
  @override
  State<Transactions> createState() => _TransactionsState();
}
class _TransactionsState extends State<Transactions> {
  late List<Transaction> transaction;
  bool _isLoading = false;
  bool _isError = false;

  @override
  void initState() {
    _getTransactions();
    super.initState();
  }

  _getTransactions() {
    setState(() {
      _isLoading = true;
    });
    WalletService().getTransactions().then((value) {
      setState(() {
        transaction = value;
        _isLoading = false;
      });
    }).catchError((e) {
      setState(() {
        _isLoading = false;
        _isError = true;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return _isLoading
        ? const Center(
            child: CircularProgressIndicator(
            color: Colors.blue,
          ))
        : _isError
            ? const Center(
                child: Text(
                  'Error getting list of transactions',
                  style: TextStyle(
                    color: Colors.red,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              )
            : Scaffold(
                appBar: AppBar(
                  title: const Text('Transactions'),
                  backgroundColor: Colors.black,
                ),
                body: ListView.builder(
                  itemCount: transaction.length,
                  itemBuilder: (context, index) {
                    return Container(
                      decoration: const BoxDecoration(
                        border: Border(
                          bottom: BorderSide(width: .5, color: Colors.grey),
                        ),
                      ),
                      padding: EdgeInsets.fromLTRB(10, 20, 10, 20),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        children: [
                          Expanded(
                            flex: 7,
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  transaction[index].name,
                                  style: TextStyle(
                                      color: Colors.black,
                                      fontWeight: FontWeight.w800),
                                ),
                                SizedBox(height: 10.0),
                                Text(transaction[index].paymentMethod)
                              ],
                            ),
                          ),
                          Column(
                            crossAxisAlignment: CrossAxisAlignment.end,
                            children: [
                              SizedBox(height: 10.0),
                              Text('\$ ${transaction[index].amount}')
                            ],
                          ),
                        ],
                      ),
                    );
                  },
                ),
              );
  }
}
Enter fullscreen mode Exit fullscreen mode

The snippet above does the following:

  • Lines 1-3: Import the required dependencies
  • Lines 11-13: Create the transactions, _isLoading, and _isError properties to manage the application state
  • Lines 15-36: Create a _getTransactions method to get the list of transactions using the WalletService().getTransactions service, set states accordingly, and use the initState method to call the _getTransactions method when the object is inserted into the tree
  • Modifies the UI widgets to show the transactions stored on Appwrite

With that done, we restart the application using the code editor or run the command below:

flutter run
Enter fullscreen mode Exit fullscreen mode

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

Conclusion

This post discussed how to use Appwrite’s database features to build a mobile wallet functionality in a Flutter application. We built a base implementation to demonstrate Appwrite's support for scaffolding a working prototype. With Appwrite, we can further extend the application's functionality by leveraging features like real-time communication, localization, authentication, etc

These resources may be helpful:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .