Integrating Celo's decentralized payment system with Flutter

Introduction

In today’s world, mobile phones are ubiquitous and powerful tools. However, accessing decentralized financial systems from mobile devices is often complicated. Celo is a blockchain platform that makes it easy to work with decentralized systems on mobile. It’s compatible with Ethereum, and also, it aims to provide fast, secure, and low-cost payment experiences.

On the other hand, Flutter is a cross-platform development tool that lets you build applications for multiple platforms, including mobile, web, and desktop, from a single codebase. Flutter’s ease of use, combined with Celo’s mobile-first approach, can help developers build incredible decentralized applications (dApps).

In this tutorial, we’ll integrate Celo’s decentralized payment system into a Flutter app. We’ll create a simple mobile app where users can connect their Celo wallets, and make transactions to other Celo addresses.

Prerequisites

Before starting this tutorial, it is recommended to have some prior knowledge and complete the following prerequisites:

  1. Basic understanding of blockchain technology (specifically CELO)
  2. Familiarity with the Flutter framework and Dart language.
  3. Prior experience with EVM would be helpful, as Celo is fully compatible with Ethereum’s tooling and coding conventions.

If you haven’t met these prerequisites, I recommend you take the time to familiarize yourself with the basics of blockchain and Flutter development before proceeding.
You don’t need to have Celo tokens for this tutorial as we’ll be working on the Alfajores Test Network provided by Celo, which uses faucet funds for testing purposes.

Requirements

Ensure you have the following installed before starting:

  1. Flutter SDK: Make sure you have Flutter SDK installed and set up. Follow Flutter’s official guide here to get started.
  2. Dart SDK: It is usually installed with Flutter, but you can check the installation guide here.
  3. An IDE (Android Studio, Visual Studio Code, etc.): Flutter supports various IDEs; you can select any that you are comfortable with.
  4. Install the Celo Alfajores Wallet on your mobile device.

Once you have everything set up, you’re ready to start building your decentralized payment app.

Getting Started with Flutter

Let’s start by creating a new Flutter project. Open your terminal and execute the following command:

flutter create celo_decentralized_payment

This command creates a new Flutter project called “celo_decentralized_payment”. Change into the new project directory:

cd celo_decentralized_payment

Open the project in your preferred IDE. You’ll see a default sample app that Flutter generates. We’ll start by cleaning up this sample app. Open lib/main.dart , delete everything, and replace it with the following:

// ignore_for_file: library_private_types_in_public_api

import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:identity_preserving_dapp/screen/home_page.dart';

void main() async {
  await dotenv.load(fileName: '.env');
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        title: 'Celo Decentralized Payment',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const HomePage(),
      ),
    );
  }
}

Adding Dependencies

To interact with Celo’s network, we need to install a couple of packages. We’re going to use riverpod, url_launcher, flutter_secure_storage, and dotenv for this tutorial. riverpod is used for managing the state of the Dapp, url_launcher to re-direct the users to the Celo alfajores app and flutter_secure_storage to store the user’s details in a secured storage.

Add the following dependencies to your pubspec.yaml file:

dependencies:
   flutter:
     sdk: flutter
  flutter_riverpod: ^2.3.4
  flutter_dotenv: ^5.0.2
  flutter_secure_storage: ^8.0.0
  url_launcher: ^6.1.10

Building the User Interface

We’ll start off by building the product screen which will list the available products under different categories like Fashion, Accessories, and Electronics. Each product will be displayed on a card with the product details and a Pay button.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:identity_preserving_dapp/controller/payment_controller.dart';
import 'package:identity_preserving_dapp/model/connect_wallet.dart';
import 'package:identity_preserving_dapp/model/product_model.dart';
import 'package:identity_preserving_dapp/screen/widgets/product_card.dart';
import 'package:identity_preserving_dapp/screen/widgets/product_header.dart';
import 'package:identity_preserving_dapp/screen/widgets/snack_bar.dart';
import 'package:identity_preserving_dapp/service/storage.dart';

class SaveUserIdentity extends ConsumerWidget {
  const SaveUserIdentity({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    Size size = MediaQuery.of(context).size;
    final storage = WalletSecuredStorage();
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 4,
        centerTitle: true,
        title: const Text(
          'Products',
          maxLines: 1,
          style: TextStyle(
              fontSize: 24, fontWeight: FontWeight.w800, color: Colors.black),
        ),
      ),
      body: SafeArea(
          child: SingleChildScrollView(
        child: Padding(
          padding: EdgeInsets.symmetric(
            horizontal: size.width * 0.04,
          ),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              const SizedBox(height: 20),
              const ProductHeader(text: "Fashion Category"),
              const SizedBox(height: 15),
              SizedBox(
                height: 180,
                child: ListView.separated(
                  separatorBuilder: (_, __) => const SizedBox(width: 10),
                  shrinkWrap: true,
                  scrollDirection: Axis.horizontal,
                  itemBuilder: (_, int index) {
                    ProductModel product =
                        ref.read(paymentProvider).fashionProducts[index];
                    return ProductCard(
                      product: product,
                      onPressed: () async {
                        ConnectWalletModel? wallet = await storage.getWallet();
                        // if (context.mounted) return;
                        if (wallet == null && context.mounted) {
                          CustomSnackbar.responseSnackbar(
                              context,
                              Colors.redAccent,
                              "Payment address not assigned!");
                        } else if (wallet != null && context.mounted) {
                          String url =
                              'celo://wallet/pay?address=${wallet.address}&amount=${product.productPrice}&comment=${product.productName}';
                          ref.read(paymentProvider).launchURL(url, context);
                        }
                      },
                    );
                  },
                  itemCount: ref.read(paymentProvider).fashionProducts.length,
                ),
              ),
              const SizedBox(height: 20),
              const ProductHeader(text: "Accessories Category"),
              const SizedBox(height: 15),
              SizedBox(
                height: 180,
                child: ListView.separated(
                  separatorBuilder: (_, __) => const SizedBox(width: 10),
                  shrinkWrap: true,
                  scrollDirection: Axis.horizontal,
                  itemBuilder: (_, int index) {
                    ProductModel product =
                        ref.read(paymentProvider).accessoryProducts[index];
                    return ProductCard(
                      product: product,
                      onPressed: () async {
                        ConnectWalletModel? wallet = await storage.getWallet();
                        // if (context.mounted) return;
                        if (wallet == null && context.mounted) {
                          CustomSnackbar.responseSnackbar(
                              context,
                              Colors.redAccent,
                              "Payment address not assigned!");
                        } else if (wallet != null && context.mounted) {
                          String url =
                              'celo://wallet/pay?address=${wallet.address}&amount=${product.productPrice}&comment=${product.productName}';
                          ref.read(paymentProvider).launchURL(url, context);
                        }
                      },
                    );
                  },
                  itemCount: ref.read(paymentProvider).accessoryProducts.length,
                ),
              ),
              const SizedBox(height: 20),
              const ProductHeader(text: "Electronics Category"),
              const SizedBox(height: 15),
              SizedBox(
                height: 180,
                child: ListView.separated(
                  separatorBuilder: (_, __) => const SizedBox(width: 10),
                  shrinkWrap: true,
                  scrollDirection: Axis.horizontal,
                  itemBuilder: (_, int index) {
                    ProductModel product =
                        ref.read(paymentProvider).electronicsProducts[index];
                    return ProductCard(
                      product: product,
                      onPressed: () async {
                        ConnectWalletModel? wallet = await storage.getWallet();
                        // if (context.mounted) return;
                        if (wallet == null && context.mounted) {
                          CustomSnackbar.responseSnackbar(
                              context,
                              Colors.redAccent,
                              "Payment address not assigned!");
                        } else if (wallet != null && context.mounted) {
                          String url =
                              'celo://wallet/pay?address=${wallet.address}&amount=${product.productPrice}&comment=${product.productName}';
                          ref.read(paymentProvider).launchURL(url, context);
                        }
                      },
                    );
                  },
                  itemCount:
                      ref.read(paymentProvider).electronicsProducts.length,
                ),
              ),
            ],
          ),
        ),
      )),
    );
  }
}

In the above code, we’re displaying a list of products. Each product card shows an image, product name, price, and a Pay button. When the Pay button is clicked, it calls the onPressed voidcallback function with the product as an argument.

The onPressed Voidcallback function generates a payment link with the recipient’s address and the amount, and redirects the user to the Valora app on Celo Alfajores to complete the payment.

onPressed: () async {
                        ConnectWalletModel? wallet = await storage.getWallet();
                        // if (context.mounted) return;
                        if (wallet == null && context.mounted) {
                          CustomSnackbar.responseSnackbar(
                              context,
                              Colors.redAccent,
                              "Payment address not assigned!");
                        } else if (wallet != null && context.mounted) {
                          String url =
                              'celo://wallet/pay?address=${wallet.address}&amount=${product.productPrice}&comment=${product.productName}';
                          ref.read(paymentProvider).launchURL(url, context);
                        }
                      },

The launchUrl function takes in a deep link variable that is used to open the Celo Wallet app and initiate a payment using the url_launcher package we installed earlier. Here’s a breakdown of the different components:

  • celo://wallet/pay: This is the base URL that the Celo Wallet app is registered to handle. When this URL is opened on a device where the Celo Wallet app is installed, the app will open and navigate to the payment screen.
  • address=$recipientAddress: This is a query parameter that specifies the recipient’s Celo address, where $recipientAddress should be replaced with the actual address which is gotten from the flutter secure storage. The Celo Wallet app will pre-fill this address in the “To” field of the payment screen.
  • amount=${product.productPrice}: This is another query parameter that specifies the amount of Celo to send. ${product.productPrice} should be replaced with the actual amount. The Celo Wallet app will pre-fill this amount in the “Amount” field of the payment screen.
  • comment=${product.productName}: This is a query parameter that specifies an optional comment for the transaction. ${product.productPrice} should be replaced with the actual comment. The Celo Wallet app will pre-fill this comment in the “Comment” field of the payment screen.
Future launchURL(String url, context) async {
    if (await canLaunchUrl(Uri.parse(url))) {
      await launchUrl(Uri.parse(url));
    } else {
      CustomSnackbar.responseSnackbar(
          context, Colors.redAccent, 'Could not launch $url');
    }
  }

Next, let’s build the Connect Wallet screen. This screen allows store owners to connect their wallet and save their details. Although in a real-world application, you might want to save these details in a smart contract on Celo blockchain, for this tutorial, we’ll save them on the Flutter Secure Storage.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:identity_preserving_dapp/model/connect_wallet.dart';
import 'package:identity_preserving_dapp/screen/widgets/celo_button.dart';
import 'package:identity_preserving_dapp/screen/widgets/celo_pop.dart';
import 'package:identity_preserving_dapp/screen/widgets/snack_bar.dart';
import 'package:identity_preserving_dapp/service/celo_helper.dart';

class CheckUserAccounts extends ConsumerStatefulWidget {
  const CheckUserAccounts({super.key});

  @override
  ConsumerState<CheckUserAccounts> createState() => _CheckUserAccountsState();
}

class _CheckUserAccountsState extends ConsumerState<CheckUserAccounts> {
  TextEditingController addressController = TextEditingController();
  TextEditingController nameController = TextEditingController();
  @override
  Widget build(BuildContext context) {
    Size size = MediaQuery.of(context).size;
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        backgroundColor: Colors.white,
        elevation: 4,
        centerTitle: true,
        title: const Text(
          'Connect Wallet',
          maxLines: 1,
          style: TextStyle(
              fontSize: 24, fontWeight: FontWeight.w800, color: Colors.black),
        ),
      ),
      body: SafeArea(
          child: SingleChildScrollView(
        child: Padding(
          padding: EdgeInsets.only(
              left: size.width * 0.04, right: size.width * 0.04, top: 20),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: <Widget>[
                  Image.asset('assets/images/logo.png', height: 80, width: 120),
                  const SizedBox(width: 10),
                  const Text(
                    'Payment',
                    maxLines: 2,
                    style: TextStyle(
                        fontSize: 30,
                        fontWeight: FontWeight.w900,
                        color: Colors.black54),
                  )
                ],
              ),
              const SizedBox(height: 20),
              Form(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    const Text(
                      'Enter your details to connect your wallet to your products on the dApp.',
                      style: TextStyle(
                          fontSize: 14,
                          fontWeight: FontWeight.w400,
                          color: Colors.black),
                    ),
                    const SizedBox(height: 30),
                    CustomTextField(
                      text: 'Business Name',
                      controller: nameController,
                      hint: 'enter your Business name',
                    ),
                    const SizedBox(height: 15),
                    CustomTextField(
                      text: 'Wallet address',
                      controller: addressController,
                      hint: 'enter wallet address for the business',
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 50),
              CustomButtonWidget(
                text: ref.watch(walletProvider).connectStatus == Status.loading
                    ? const CircularProgressIndicator(color: Colors.white)
                    : const Text(
                        'Connect Wallet',
                        style: TextStyle(
                            fontSize: 18,
                            fontWeight: FontWeight.w500,
                            color: Colors.white),
                      ),
                onPressed: () async {
                  if (nameController.text.trim().isEmpty ||
                      addressController.text.trim().isEmpty) {
                    CustomSnackbar.responseSnackbar(context, Colors.redAccent,
                        'Fill the required fields..');
                    return;
                  }
                  ConnectWalletModel wallet = ConnectWalletModel(
                      businessName: nameController.text.trim(),
                      address: addressController.text.trim());
                  await ref
                      .read(walletProvider)
                      .connectWalletMethod(wallet, context);
                },
              ),
            ],
          ),
        ),
      )),
    );
  }
}

Finally, to monitor the payment flow, we need to listen to the Celo blockchain for incoming transactions to the store owner’s address. This can be done by hooking into the Celo network and checking the latest transactions for the store owner’s address. If a new incoming transaction is detected, you can update the user interface to reflect that the payment was successful.

Please note that this operation requires backend implementation and it’s beyond the scope of this tutorial. However, here’s a rough idea of how it could work:

// Note: This is a simplified and hypothetical code snippet.
void monitorPayments(String address) {
  Stream<Transaction> transactions = getCeloTransactions(address);

  transactions.listen((transaction) {
    if (transaction.to == address && transaction.value > 0) {
      print('Payment received: ${transaction.value} cUSD');
      // Update the UI here...
    }
  });
}

Conclusion

Congratulations on completing this tutorial! You have successfully integrated Celo’s decentralized payment system with a Flutter application. We’ve covered a lot of ground: setting up the development environment, building a simple product interface, and enabling mobile payments using Celo and Valora app.

With your new-found knowledge, you are well-equipped to build more complex applications leveraging Celo’s mobile-first blockchain infrastructure. Remember, the beauty of blockchain technology is that it opens up the possibility of building decentralized, secure, and transparent financial systems. And with Celo and Flutter, you can do just that for mobile users across the globe!

Keep exploring and building. The decentralized future is in your hands!

Next Steps

By following these steps above, you should have a basic Flutter application that can create celo payment requests.
While this application is very basic, it serves as a starting point for integrating Celo payments into your own applications. From here, you could add more features, such as support for different tokens on the Celo network, improved error handling, and a more user-friendly interface.

To check the status of a transaction directly from your Flutter app, you need to interact with the Celo network or a Celo node.

Here’s a typical way you could handle it:

Backend Server:

  1. Create a backend service (Node.js, Python, or any backend language you are comfortable with).
  2. The backend service would interact with the Celo network using a library like @celo/contractkit in Node.js.
  3. After the user initiates a transaction from the Flutter app, the backend service would continuously monitor the status of the transaction on the Celo network through a webhook.
  4. Once the transaction is successful, the backend service would update the status in your application database.

Flutter App:

  1. The Flutter app would send a request to initiate the transaction and then periodically send requests to the backend service to check the status of the transaction. Or you can make it such that the user clicks on a button called confirm transaction after making a payment.
  2. Once the backend service responds that the transaction is successful, the Flutter app would update the UI to reflect the transaction’s success.

About the Author

John Igwe Eke, a proficient mobile developer and a technical writer, combines his expertise in software development with his enthusiasm for blockchain and Web3 technologies. He leverages this unique blend of skills to distill complex concepts into understandable, engaging content, thereby aiding in the democratization of these emerging technologies. Connect with me on Twitter, LinkedIn, and Github.

References

  1. Flutter Official Documentation
  2. Celo Official Documentation
  3. Dart Language
  4. Source Code
4 Likes

Congratulations on your proposal being chosen as a standout this week at Celo Academy! As you prepare your tutorial, please ensure you follow our guidelines found here: Celo Sage Guidelines and Best Practices. Thank you for helping improve the experience of the developers learning at Celo Academy.

4 Likes

Hi @johnigwe I’ll be reviewing your piece in 1 to 2 days

1 Like

Alright @Phenzic
Thank you