Building a cryptocurrency app with Flutter bloc 8-0
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.