Build a CELO NFT Explorer with Flutter and Celo Composer

Build a CELO NFT Explorer with Flutter and Celo Composer https://celo.academy/uploads/default/optimized/2X/8/8fb67741e6ad66be776d8078ed50bc50ca3b13c2_2_1024x576.jpeg
none 0.0 0

Introduction

In this tutorial, I will be explaining on how to build a CELO based p2p payment dApp where user can send funds to each other on CELO network with QR code with Flutter using Celo Composer for bootstrap.

Prerequisites

First, This tutorial assumes that you are already familiar with solidity and understand how smart contracts work and also assumes that you already know the basics of using Flutter or you can please clone the flutter celo nft. You’ll also want to make sure you’ve installed the MetaMask Mobile App and have a MetaMask account. If you’d like, see our prior tutorial Setting Up Your Wallet for more details on this.

For this project we’ll be using a few interesting dependencies & dev dependencies:

We’ll walk you through each one as we go and show you how to use them in combination to write some awesome dapps.

Getting Started

I assume that anyone going through this tutorial already understands and uses Flutter, so I will skip the setup involved in getting Flutter to work on your development computer. That means I assume you already have VS Code/Android Studio together with Android SDK and Flutter setup on your PC.

  • If you are entirely new to Flutter, Flutter get started is a good tutorial you can learn from.

To get started, you can clone this repository flutter-celo-nft or make use of Celo Composer.

Celo Composer is a set of tools and starter templates that makes it easy for you to start your Celo-based web3 projects.

You can check out Celo Composer.

Using Celo Composer

To start our Flutter App Project, we will use the Celo Composer CLI. The CLI makes it easy for us to select the options that we want when bootstrapping our dApp. In other to do that you need to have Node set up on your PC and you can visit Node to get started. Run this on your terminal after setup Node.

npx @celo/celo-composer create

Choose Flutter when asked for the framework

Your Project will now be created, you can check to make sure it has the following folders

packages/flutter-app - Your Flutter project

Manage Environmental variable

Create a .env file at the root of your project inside flutter-app folder in packages and add the follow variable

ETHEREUM_RPC=https://alfajores-forno.celo-testnet.org

In other to get nfts data, we will be using moralis to get nft data from Celo blockchain to do so you need to obtain your moralis api key by login to moralis dashboard. Update your .env file with moralis web3 api key and your .env file should now look like below

ETHEREUM_RPC=https://alfajores-forno.celo-testnet.org
MORALIS_WEB3_API_KEY=********

UI

To manage user nft on wallet authenticated, we need to create a cubit file to manage nft state. In lib folder, open module/home folder then create new folder name model and create the nft model file in it you can name it anything then update the file with the code snippet below

// ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert';
class NftModel {
  String? tokenAddress;
  String? tokenId;
  String? tokenUri;
  String? contractType;
  NormalizedMetadata? normalizedMetadata;
  NftModel({
    this.tokenAddress,
    this.tokenId,
    this.tokenUri,
    this.contractType,
    this.normalizedMetadata,
  });
  NftModel copyWith({
    String? tokenAddress,
    String? tokenId,
    String? tokenUri,
    String? contractType,
    NormalizedMetadata? normalizedMetadata,
  }) {
    return NftModel(
      tokenAddress: tokenAddress ?? this.tokenAddress,
      tokenId: tokenId ?? this.tokenId,
      tokenUri: tokenUri ?? this.tokenUri,
      contractType: contractType ?? this.contractType,
      normalizedMetadata: normalizedMetadata ?? this.normalizedMetadata,
    );
  }
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'token_address': tokenAddress,
      'token_id': tokenId,
      'token_uri': tokenUri,
      'contract_type': contractType,
      'normalized_metadata': normalizedMetadata?.toMap(),
    };
  }
  factory NftModel.fromMap(Map<String, dynamic> map) {
    return NftModel(
      tokenAddress:
          map['token_address'] != null ? map['token_address'] as String : null,
      tokenId: map['token_id'] != null ? map['token_id'] as String : null,
      tokenUri: map['token_uri'] != null ? map['token_uri'] as String : null,
      contractType:
          map['contract_type'] != null ? map['contract_type'] as String : null,
      normalizedMetadata: map['normalized_metadata'] != null
          ? NormalizedMetadata.fromMap(
              map['normalized_metadata'] as Map<String, dynamic>)
          : null,
    );
  }
  String toJson() => json.encode(toMap());
  factory NftModel.fromJson(String source) =>
      NftModel.fromMap(json.decode(source) as Map<String, dynamic>);
  @override
  String toString() {
    return 'NftModel(tokenAddress: $tokenAddress, tokenId: $tokenId, tokenUri: $tokenUri, contractType: $contractType, normalizedMetadata: $normalizedMetadata)';
  }
  @override
  bool operator ==(covariant NftModel other) {
    if (identical(this, other)) return true;
    return other.tokenAddress == tokenAddress &&
        other.tokenId == tokenId &&
        other.tokenUri == tokenUri &&
        other.contractType == contractType &&
        other.normalizedMetadata == normalizedMetadata;
  }
  @override
  int get hashCode {
    return tokenAddress.hashCode ^
        tokenId.hashCode ^
        tokenUri.hashCode ^
        contractType.hashCode ^
        normalizedMetadata.hashCode;
  }
}
/// Check https://docs.moralis.io/web3-data-api/evm/reference/get-wallet-nfts?address=0xd8da6bf26964af9d7eed9e03e53415d37aa96045&chain=eth&format=decimal&token_addresses=[]&media_items=false
/// for the rest of meta data fields
class NormalizedMetadata {
  String? name;
  String? description;
  String? image;
  NormalizedMetadata({
    this.name,
    this.description,
    this.image,
  });
  NormalizedMetadata copyWith({
    String? name,
    String? description,
    String? image,
  }) {
    return NormalizedMetadata(
      name: name ?? this.name,
      description: description ?? this.description,
      image: image ?? this.image,
    );
  }
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'name': name,
      'description': description,
      'image': image,
    };
  }
  factory NormalizedMetadata.fromMap(Map<String, dynamic> map) {
    return NormalizedMetadata(
      name: map['name'] != null ? map['name'] as String : null,
      description:
          map['description'] != null ? map['description'] as String : null,
      image: map['image'] != null ? map['image'] as String : null,
    );
  }
  String toJson() => json.encode(toMap());
  factory NormalizedMetadata.fromJson(String source) =>
      NormalizedMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
  @override
  String toString() =>
      'NormalizedMetadata(name: $name, description: $description, image: $image)';
  @override
  bool operator ==(covariant NormalizedMetadata other) {
    if (identical(this, other)) return true;
    return other.name == name &&
        other.description == description &&
        other.image == image;
  }
  @override
  int get hashCode => name.hashCode ^ description.hashCode ^ image.hashCode;
}

To render the nft, we need to create another folder inside the module/home folder name widgets (which we will be using to manage all home widgets). Create a new data file name nft_card.dart (You can use any name you like though) and update it with below code

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_celo_composer/module/home/model/nft_model.dart';
class NftCard extends StatelessWidget {
  const NftCard({
    required this.nft,
    super.key,
  });
  final NftModel nft;
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(5),
      decoration: BoxDecoration(
          color: const Color(0xFFFCFF52).withOpacity(0.3),
          borderRadius: BorderRadius.circular(10),
          boxShadow: <BoxShadow>[
            BoxShadow(
                offset: const Offset(0, 4),
                blurRadius: 12,
                spreadRadius: 0,
                color: Colors.black.withOpacity(0.04))
          ]),
      child: Column(
        children: <Widget>[
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(20),
                  image: DecorationImage(
                      image: CachedNetworkImageProvider(
                          nft.normalizedMetadata?.image ?? ''))),
            ),
          ),
          Text(
            '#${nft.tokenId}',
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context)
                .textTheme
                .headlineSmall!
                .copyWith(color: Colors.black),
          )
        ],
      ),
    );
  }
}

For cubit, create new folder in module/home name services/cubit, and create a file for both state and cubit then update them respectfully

import 'dart:convert';
import 'package:flutter_celo_composer/module/home/model/nft_model.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:walletconnect_dart/walletconnect_dart.dart';
part 'nft_state.dart';
class NftCubit extends Cubit<NftState> {
  NftCubit({required this.connector}) : super(const NftState());
  final WalletConnect connector;
  Future<void> fetchNfts() async {
    try {
      emit(FetchNftLoading());
      final http.Response res = await http.Client().get(
          Uri.parse(
              'https://deep-index.moralis.io/api/v2/${connector.session.accounts[0]}/nft?chain=eth&format=decimal&media_items=false'),
          headers: <String, String>{
            'Accept': 'application/json',
            'X-API-Key': dotenv.get('NFTCOLLECTION_CONTRACT_ADDRESS')
          });
      if (!<int>[200, 201, 202].contains(res.statusCode)) {
        throw 'Failed to fetch data';
      }
      List<dynamic> rawdata = json.decode(res.body)['result'];
      emit(FetchNftSuccess(
          nfts: rawdata
              .map((dynamic e) => NftModel.fromMap(e as Map<String, dynamic>))
              .toList()));
    } catch (e) {
      emit(FetchNftFailed(errorCode: '', message: e.toString()));
    }
  }
}

For nft state,

part of 'nft_cubit.dart';
class NftState {
  const NftState();
}
class FetchNftLoading extends NftState {
  FetchNftLoading();
}
class FetchNftSuccess extends NftState {
  const FetchNftSuccess({required this.nfts});
  final List<NftModel> nfts;
}
class FetchNftFailed extends NftState {
  const FetchNftFailed({
    required this.errorCode,
    required this.message,
  });
  final String errorCode;
  final String message;
}

Lastly, let’s update both home_screen.dart and main.dart file with new changes.

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_celo_composer/module/home/model/nft_model.dart';
import 'package:flutter_celo_composer/module/home/services/cubit/nft_cubit.dart';
import 'package:flutter_celo_composer/module/home/widgets/nft_card.dart';
import 'package:jazzicon/jazzicon.dart';
import 'package:jazzicon/jazziconshape.dart';
import 'package:walletconnect_dart/walletconnect_dart.dart';
class HomeScreen extends StatefulWidget {
  const HomeScreen({
    required this.session,
    required this.connector,
    required this.uri,
    Key? key,
  }) : super(key: key);
  final dynamic session;
  final WalletConnect connector;
  final String uri;
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
  String accountAddress = '';
  String networkName = '';
  TextEditingController greetingTextController = TextEditingController();
  JazziconData? jazz;
  ButtonStyle buttonStyle = ButtonStyle(
    elevation: MaterialStateProperty.all(0),
    backgroundColor: MaterialStateProperty.all(
      Colors.white.withAlpha(60),
    ),
    shape: MaterialStateProperty.all(
      RoundedRectangleBorder(borderRadius: BorderRadius.circular(25)),
    ),
  );
  @override
  void initState() {
    super.initState();
    /// Execute after frame is rendered to get the emit state of InitializeProviderSuccess
    WidgetsBinding.instance.addPostFrameCallback((_) {
      accountAddress = widget.connector.session.accounts[0];
      jazz = Jazzicon.getJazziconData(40,
          address: widget.connector.session.accounts[0]);
      setState(() {});
      context.read<NftCubit>().fetchNfts();
    });
  }
  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final double width = MediaQuery.of(context).size.width;
    final double height = MediaQuery.of(context).size.height;
    List<NftModel> nfts = [];
    return BlocListener<NftCubit, NftState>(
      listener: (BuildContext context, NftState state) {
        if (state is FetchNftSuccess) {
          nfts = state.nfts;
        } else if (state is FetchNftFailed) {
        } else if (state is FetchNftLoading) {}
      },
      child: Scaffold(
        appBar: AppBar(
          elevation: 0,
          title: Image.asset(
            'assets/images/logo.png',
            width: 50,
            height: 50,
          ),
          centerTitle: true,
          // ignore: use_decorated_box
          backgroundColor: const Color(0xFFFCFF52),
        ),
        backgroundColor: Colors.white,
        body: Column(
          children: <Widget>[
            Column(
              children: [
                const SizedBox(height: 10),
                if (accountAddress.isNotEmpty)
                  Text(
                    'Address: ${accountAddress.substring(0, 8)}...${accountAddress.substring(accountAddress.length - 8, accountAddress.length)}',
                    style: theme.textTheme.titleMedium!.copyWith(
                      color: Colors.black,
                    ),
                  ),
              ],
            ),
            Expanded(
              child: GridView.builder(
                padding:
                    const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
                gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                  crossAxisCount: 2,
                  childAspectRatio: 1 / 1.08,
                  crossAxisSpacing: 10,
                  mainAxisSpacing: 10,
                ),
                shrinkWrap: true,
                itemBuilder: (_, int index) {
                  return NftCard(
                    nft: nfts[index],
                    key: Key(index.toString()),
                  );
                },
                itemCount: nfts.length,
              ),
            ),
            Container(
              padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10),
              child: SizedBox(
                width: width,
                child: ElevatedButton.icon(
                  onPressed: () {
                    //context.read<AuthCubit>().();
                  },
                  icon: const Icon(
                    Icons.power_settings_new,
                  ),
                  label: Text(
                    'Disconnect',
                    style: theme.textTheme.titleMedium!
                        .copyWith(color: Colors.black),
                  ),
                  style: ButtonStyle(
                    elevation: MaterialStateProperty.all(0),
                    backgroundColor: MaterialStateProperty.all(
                      Colors.grey.shade400,
                    ),
                    shape: MaterialStateProperty.all(
                      RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(25),
                      ),
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

For main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_celo_composer/configs/themes.dart';
import 'package:flutter_celo_composer/configs/web3_config.dart';
import 'package:flutter_celo_composer/infrastructures/repository/secure_storage_repository.dart';
import 'package:flutter_celo_composer/infrastructures/service/cubit/secure_storage_cubit.dart';
import 'package:flutter_celo_composer/module/auth/interfaces/screens/authentication_screen.dart';
import 'package:flutter_celo_composer/module/auth/service/cubit/auth_cubit.dart';
import 'package:flutter_celo_composer/module/home/services/cubit/nft_cubit.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:walletconnect_dart/walletconnect_dart.dart';
import 'package:web3dart/web3dart.dart';
Future<void> main() async {
  /// Load env file
  await dotenv.load();
  runApp(
    MyApp(
      walletConnect: await walletConnect,
      web3client: web3Client,
    ),
  );
}
class MyApp extends StatelessWidget {
  const MyApp({
    required this.walletConnect,
    required this.web3client,
    Key? key,
  }) : super(key: key);
  final WalletConnect walletConnect;
  final Web3Client web3client;
  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: <BlocProvider<dynamic>>[
        BlocProvider<AuthCubit>(
          create: (BuildContext context) => AuthCubit(
            storage: SecureStorageRepository(),
            connector: walletConnect,
          ),
        ),
        BlocProvider<NftCubit>(
          create: (BuildContext context) => NftCubit(
            connector: walletConnect,
          ),
        ),
        BlocProvider<SecureStorageCubit>(
          create: (BuildContext context) => SecureStorageCubit(
            storage: SecureStorageRepository(),
          ),
        ),
      ],
      child: MaterialApp(
        title: 'Sophon',
        debugShowCheckedModeBanner: false,
        theme: buildDefaultTheme(context),
        home: const MyHomePage(),
      ),
    );
  }
}
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key}) : super(key: key);
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    /// Lock app to portrait mode
    SystemChrome.setPreferredOrientations(<DeviceOrientation>[
      DeviceOrientation.portraitUp,
      DeviceOrientation.portraitDown,
    ]);
    return const AuthenticationScreen();
  }
}

Incase you encounter any error with test folder, make sure to update any necessary file changes. Then run your project, you should be able to get something similar with below screenshot.


Conclusion

Congratulations, you have now learned how to build a real-world dApp using Flutter. You have seen how to connect with a blockchain wallet, and play around with NFT.

About the Author

I am a Software Engineer, Tech Evangelist (Preaching the gospel of flutter & blockchain) also and Ex-GDSC Leads.

References

3 Likes

Easy ans straightfoward, welldone.

1 Like

Great tutorial. Well written

@mujhtech Its good to see how you leverage flutter to help developers. Please be mindful and endeavor to reference any external resources you use in your tutorials, This is very important to avoid copyright issues. The image you used is not referenced.

3 Likes