Building a cryptocurrency app with Flutter bloc 8-0

·

11 min read

Building a crypto currency app with flutter_bloc 8.0 The Flutter BLoC team recently upgraded to flutter_bloc 8.0 and has decided to introduce event handlers as replacements to mapEventToState used in previous versions amongst other features. In this article, we would learn to use flutter_bloc 8.0 features properly to build an app. Our app would be getting the data for bitcoin from an API and displaying it on the screen.

This tutorial was prepared mostly for Flutter newbies still trying to integrate an API and use a state management solution(In our case, BLoC) in their app.

Key BLoC Areas

  • Observe state changes with BlocObserver
  • BlocProvider, Flutter widget that provides a bloc to its children
  • BlocBuilder, Flutter widget that handles building the widget in response to new states
  • Prevent unnecessary rebuilds with Equatable

Setup

flutter create coin_cap

Project Architecture

Following the official bloc architecture guideline, our application can be divided into layers. In this tutorial we have three layers:

  • Data: retrieve raw coin data from the API
  • Repository
  • Business Logic: manage the state of the app.
  • Presentation: display market activity for the selected coin.

We can then go ahead and replace the contents of pubspec.yaml with

name:coin_cap
description: A new Flutter project.
publish_to: 'none' 
version: 1.0.0+1
environment:
  sdk: ">=2.15.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  flutter_bloc: ^8.0.0
  http: ^0.13.4
  equatable: ^2.0.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.0.17
  bloc_test: ^9.0.1
  flutter_lints: ^1.0.0

flutter:
  uses-material-design: true

Install all of our dependencies with the comman :

flutter packages get

Project Structure

coin_cap
|-- lib/
  |-- bloc/
     |-- coincap_bloc.dart
     |-- coincap_event.dart
     |-- coincap_state.dart
  |-- data/
     |-- coin_cap_client.dart
     |-- data.dart*
  |-- models/
     |-- coin_cap.dart
     |-- models.dart*
  |-- view/
     |-- home_screen.dart
     |-- view.dart*
  |-- Repository/
     |-- coin_cap_client.dart
     |-- repository.dart*
  |-- main.dart
|-- test/

Barrel files are indicated by the asterisk (*).

Rest API

In this application, we would be using the CoinCap API. CoinCap is a useful tool for real-time pricing and market activity for over 1,000 cryptocurrencies. We would be using one endpoint api.coincap.io/v2/assets/bitcoin to get bitcoin’s markets activity for our app. The response from the endpoint would be structured as followed:

Endpoint structure

{
  "data": {
    "id": "bitcoin",
    "rank": "1",
    "symbol": "BTC",
    "name": "Bitcoin",
    "supply": "17193925.0000000000000000",
    "maxSupply": "21000000.0000000000000000",
    "marketCapUsd": "119179791817.6740161068269075",
    "volumeUsd24Hr": "2928356777.6066665425687196",
    "priceUsd": "6931.5058555666618359",
    "changePercent24Hr": "-0.8101417214350335",
    "vwap24Hr": "7175.0663247679233209"
  },
  "timestamp": 1533581098863
}

Now we know the structure for our response, in the models' folder we would create a file coin_cap.dart which will hold the data model for the CoinCap class.

Imports

The first thing we need to do is import the equatable dependency into our CoinCap class.

import 'package:equatable/equatable.dart'

Our coin_cap.dart class will look like this

import 'dart:convert';
import 'package:equatable/equatable.dart';
CoinCap coinCapFromJson(String str) => CoinCap.fromJson(json.decode(str));
String coinCapToJson(CoinCap data) => json.encode(data.toJson());
class CoinCap extends Equatable{
    const CoinCap({
        this.data,
        this.timestamp,
    });
    final Data? data;
    final int? timestamp;
    factory CoinCap.fromJson(Map<String, dynamic> json) => CoinCap(
        data: Data.fromJson(json["data"]),
        timestamp: json["timestamp"],
    );
    Map<String, dynamic> toJson() => {
        "data": data!.toJson(),
        "timestamp": timestamp,
    };
  @override
  List<Object?> get props => [data,timestamp];
}
class Data extends Equatable{
    const Data({
        required this.id,
        required this.rank,
        required this.symbol,
        required this.name,
        required this.supply,
        required this.maxSupply,
        required this.marketCapUsd,
        required this.volumeUsd24Hr,
        required this.priceUsd,
        required this.changePercent24Hr,
        required this.vwap24Hr,
    });
    final String? id;
    final String? rank;
    final String? symbol;
    final String? name;
    final String? supply;
    final String? maxSupply;
    final String? marketCapUsd;
    final String? volumeUsd24Hr;
    final String? priceUsd;
    final String? changePercent24Hr;
    final String? vwap24Hr;
    factory Data.fromJson(Map<String, dynamic> json) => Data(
        id: json["id"],
        rank: json["rank"],
        symbol: json["symbol"],
        name: json["name"],
        supply: json["supply"],
        maxSupply: json["maxSupply"],
        marketCapUsd: json["marketCapUsd"],
        volumeUsd24Hr: json["volumeUsd24Hr"],
        priceUsd: json["priceUsd"],
        changePercent24Hr: json["changePercent24Hr"],
        vwap24Hr: json["vwap24Hr"],
    );
    Map<String, dynamic> toJson() => {
        "id": id,
        "rank": rank,
        "symbol": symbol,
        "name": name,
        "supply": supply,
        "maxSupply": maxSupply,
        "marketCapUsd": marketCapUsd,
        "volumeUsd24Hr": volumeUsd24Hr,
        "priceUsd": priceUsd,
        "changePercent24Hr": changePercent24Hr,
        "vwap24Hr": vwap24Hr,
    };
  @override
  List<Object?> get props => [id,rank,symbol,name,supply, maxSupply,marketCapUsd,volumeUsd24Hr,priceUsd,changePercent24Hr,vwap24Hr];
}

You can generate this easily by heading over to app.quicktype.io insert the endpoint structure, giving it a name, and copying the generated code for your class. You would have to extend the “equatable” manually.

We extend Equatable so that we can compare Coin cap assets. Without this, we would need to manually change our class to override equality and hashCode so that we could tell the difference between two objects. See the package for more details.

Export in Barrel

Although we only have one data model for this project, but this is a best practice and a good habit to do. In case we want to scale our apps, exporting functions in barrel will help us in managing which files we need to expose and reduce dependencies import in our code. Create a new file, lib/models/models.dart as our barrel file and add the following code:

Export in Barrel

Although we only have one data model for this project, this is a best practice and a good habit to do. In case we want to scale our apps, exporting functions in a barrel file will help us in managing which files we need to expose and reduce dependencies import into our code. Create a new file, lib/models/models.dart as our barrel file and add the following code:

export 'coin_cap.dart';

That’s all for our Data Model, now let’s move on to the data layer.

Data Layer

Now, we will build our CoinCapClient that will responsible for making HTTP requests to our API. As the lowest layer in our app's architecture, CoinCapClient responsibility is only to fetch data directly from API. We only need to expose one public function called getCoinAsset, which returns a map of coin cap assets.

Our CoinCapClass is a simple class and the final code would look like this:

import 'package:coin_cap/models/models.dart';
import 'package:http/http.dart' as http ;
import 'dart:convert';

class CoinCapClient{
static const _baseUrl = 'https://api.coincap.io/v2/assets';
http.Client httpClient;
CoinCapClient({required this.httpClient});

Future<CoinCap> getCoinAsset()async {
  final url = Uri.parse("$_baseUrl/bitcoin");
  final response = await http.get(url);
   if (response.statusCode != 200) {
      throw  Exception('error getting coin cap assets');
    }
    final json = jsonDecode(response.body);
    return CoinCap.fromJson(json);
    // or
    //return coinCapFromJson(response.body);
    }
}

We are creating a private constant as our baseUrl. The constructor will inject the HTTP client and instantiate it. getCoinAsset is an asynchronous function that should return a Future of CoinCap, which is the data model object from JSON of our REST API response.

Barrel

Create a new file, lib/data/data.dart as our barrel file for the data folder and add the following code:

export 'coin_cap_client.dart';

Repository Layer

This is an abstraction layer that will be served our client code and data provider. Our CoinCapRepository will have CoinCapClient as its dependency and expose the getCoinAsset method.

The code for lib/repository/coin_cap_repository.dart would look like:

import 'package:coin_cap/data/data.dart';
import 'package:coin_cap/models/coin_cap.dart';
class CoinCapRespository{
  CoinCapClient? coinCapClient;
  CoinCapRespository({required this.coinCapClient});

  Future<CoinCap> getCoinAsset() async{
    final response = coinCapClient!.getCoinAsset();
    return response;
  }
}

In CoinCapRepository class we receive CoinCapClient as an argument in the constructor. Finally a method getCoinAsset that calls coinCapClient!.getCoinAsset().

Barrel Create a barrel file lib/repository/repository.dart and add,

export 'coin_cap_repository.dart';  

Business Logic

We are now at the most important part, which is the business logic (BLoC). For the sake of anyone new to BLoC, I would explain the basics. BLoC (Business Logic Controller) is a state management solution which means it allows you separate business logic from UI. BLoC is like a pipe, Events go in from one end and state comes out the other end.

CoinCap Event

Our CoinCapBloc will only be responding to a single event; FetchCoinAsset which will be added by the presentation layer when the app starts initially. Since our FetchCoinAsset event is a type of CoinCapEvent we can create lib/bloc/coincap_event.dart and implement the event like so.

part of 'coincap_bloc.dart';
@immutable
 class CoincapEvent extends Equatable {
  @override
  List<Object?> get props => [];
}
class FetchCoinAsset extends CoincapEvent{}

Our CoinCapBloc will be receiving CoinCapEvents and converting them to CoinCapState. We have defined all of our CoinCapEvents (FetchCoinAsset) so next, let’s define our CoinCapState.

CoinCap State

Our presentation layer will need to have several pieces of information to properly lay itself out Our CoinCap state will be created in lib/bloc/coincap_event.dart and CoinCap bloc can be any of the following

  • CoincapInitial - App's initial state when the app is run. We will add the FetchCoinAsset event in this state.
  • CoincapLoadingState - will tell the presentation layer it needs to render a loading indicator while data fetched from an endpoint is in place.
  • CoincapErrorState - is triggered when there is an error fetching data from API.
  • CoincapLoadedState - State rendered when data fetch was successful.
part of 'coincap_bloc.dart';

@immutable
 class CoincapState extends Equatable{
  @override
  List<Object?> get props => [];
}
class CoincapInitial extends CoincapState {}

class CoincapLoadingState extends CoincapState {}

class CoincapLoadedState extends CoincapState {
  final CoinCap coinCapAsset;
  CoincapLoadedState({required this.coinCapAsset});
}
class CoincapErrorState extends CoincapState {}

As noticed CoincapLoadedState gets additional parameters because in this state, our Bloc successfully fetches a CoinCap and we will tell our views to present the coin's information to the user.

We extend Equatable to optimize our code by ensuring that our app does not trigger rebuilds if the same state occurs.

CoinCap Bloc

Coincap Bloc lets Coincap event and state communicates with each other. If you haven’t already, create lib/bloc/coincap_bloc.dart and create an empty CoincapBloc

import 'dart:async';
import 'package:coin_cap/models/models.dart';
import 'package:coin_cap/repository/coin_cap_repository.dart';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
part 'coincap_event.dart';
part 'coincap_state.dart';

class CoincapBloc extends Bloc<CoincapEvent, CoincapState> {
  CoinCapRespository coinCapRespository;
  CoincapBloc({required this.coinCapRespository}) : super(CoincapInitial()) {
    on<FetchCoinAsset>(fetchAsset);
  }

  FutureOr<void> fetchAsset(FetchCoinAsset event, Emitter<CoincapState>     emit) async{
    emit(CoincapLoadingState());
      try {
        final CoinCap coinCapAsset = await 
            coinCapRespository.getCoinAsset();
        emit(CoincapLoadedState(coinCapAsset: coinCapAsset));
      } catch (_) {
        emit(CoincapErrorState());
      }
  }
}

We have to assign each event to an event handler for improved readability, I like to break out each event handler into its helper function. In our code implementation, the fetchAsset method is the event handler for the FetchCoinAsset event. In our event handler instead of calling yield, we’re using the emit function that passes in to emit these new states into our BLoC. We first emit CoinCapLoadingstate when the event, FetchCoinAsset is added. CoincapBloc constructor injects CoinCapRespository since we need it to getCoinAsset. We try to get our coinCapAsset from our API. If we succeed, we emit a CoincapLoadedState with the data. Meanwhile, if we fail we emit the CoincapErrorState. Before we link our bloc to the presentation layer, let’s set up a simple bloc observer, with this we can have access to all our changes in one place.

Bloc Observer

Since Bloc extends BlocBase, we can observe all state changes for a Bloc using onChange. Our bloc observer would be in the lib/bloc_observer.dart directory.

import 'package:flutter_bloc/flutter_bloc.dart';

class AppBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    // ignore: avoid_print
    if (bloc is Cubit) print(change);
  }
  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    // ignore: avoid_print
    print(transition);
  }
 @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    print('${bloc.runtimeType} $error $stackTrace');
    super.onError(bloc, error, stackTrace);
  }
}

As we can see we are also overriding onTransition to observe all transactions that occur from a single place. In each transition made between states, we just print the transition so you can see it in the terminal.

Presentation Layer

We will start by updating main.dart learning how to initialize AppBlocObserver.

void main() {
  BlocOverrides.runZoned(
    () => runApp( MyApp()),
   blocObserver: AppBlocObserver(),
  );
}

Still, in our main.dart file, we would use BlocProvider, a dependency injection widget to provide a bloc to its children so that a single instance of a bloc can be provided to multiple widgets within a subtree.

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CoincapBloc(),
      child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const HomeScreen()),
    );
  }
}

From our code, every widget within MaterialApp widget subtree has access to the CoincapBloc provided. CoinCapRepository is a required parameter for the CoincapBloc so we add it.

The MyApp widget will be a StatelessWidget that has CoinCapRepository injected and build a MaterialApp with HomeScreen widget. We use BlocProvider to create an instance of CoincapBloc and manage it.

So our final main.dart file looks like this.

import 'package:article_app/data/coin_cap_client.dart';
import 'package:article_app/repository/coin_cap_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'business_logic/bloc/coincap_bloc.dart';
import 'bloc_observer.dart';
import 'view/home_screen.dart';

void main() {
  final CoinCapRespository coinCapRespository = CoinCapRespository(
      coinCapClient: CoinCapClient(httpClient: http.Client()));
  BlocOverrides.runZoned(
    () =>
  runApp( MyApp(
    coinCapRespository: coinCapRespository,
  )),
   blocObserver: AppBlocObserver(),
  );
}
class MyApp extends StatelessWidget {
  final CoinCapRespository coinCapRespository;
  const MyApp({Key? key, required this.coinCapRespository}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CoincapBloc(coinCapRespository: coinCapRespository),
      child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: const HomeScreen()),
    );
  }
}

Home Screen

Our HomeScreen widget is simple, let’s create a file in lib/views with name home_screen.dart, and add the following code:

import 'package:coin_cap/business_logic/bloc/coincap_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);
  @override
  _HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: BlocBuilder<CoincapBloc, CoincapState>(
        builder: (context, state) {
          if(state is CoincapInitial){
           //BlocProvider.of<CoincapBloc>(context).add(FetchCoinAsset());
          //or
           context.read<CoincapBloc>().add(FetchCoinAsset());
          }
          if(state is CoincapErrorState){
            return const Center(child: Text("An Error occured"),);
          }
           if(state is CoincapLoadingState){
            return const Center(child: CircularProgressIndicator(),);
          }
           if(state is CoincapLoadedState){
            return  ListTile(
              leading: Text(
                '${state.coinCapAsset.data!.symbol}',
                style: const TextStyle(fontSize: 30.0),
              ),
              title: Text(state.coinCapAsset.data!.name.toString()),
              subtitle: Text('${state.coinCapAsset.data!.priceUsd}',

              ),
            );
          }
          return const Center(child: Text("This is the default"),) ;
        },
      ),
    );
  }
}

We are using BlocBuilder to build our UI based on CoincapState. When in its initial state, CoincapInitial state, we add FetchCoinAsset event. If we run into an error while fetching the data from the API, the CoincapErrorState will be triggered, and "An Error occurred" text will be displayed on the screen. CoincapLoadingState shows a CircularProgressIndicator widget. If we successfully fetch the data, CoincapLoadedState is triggered and a Listile widget with some information about the coin is displayed.

Home Screen