I Am Rick (Episode 8): Rick’s Decentralized Exchange

Building a Flutter app to track the prices on Loopring DEX.

Alexandros Baramilis
Flutter Community
15 min readMay 11, 2020

--

Intro

If you haven’t been following the series so far, you can check out the previous episodes here:

or have a look at the Github repo for the series.

If you’re having trouble installing Flutter on macOS Catalina, check out Setting up Flutter on macOS Catalina.

ALERT: Next episodes are out!

As to why I’m writing this series:

The best way to learn something is to put it into practice and then write about it, so I’m making this series based on the notes that I take as I go through App Brewery’s Flutter Bootcamp. I usually go above and beyond the course to make something cool and learn more stuff. Publishing my notes and code also forces me to maintain a high standard and allows me to easily go back for a quick review.

As for the Rick Grimes theme, it all started from a typo when I was making the first app in the series (I Am Rich). I accidentally typed ‘I Am Rick’ when I was setting up the project in Android Studio. The rest is history.

Rick’s DEX

Sometime back in Season 9, Rick got abducted in a helicopter by this advanced society that wants to build a new world, a better world.

However, as soon as he arrived there, he realised they were facing a problem. They wanted to build a new way to trade products, services, currencies, etc in a trust-less way, that is accessible by everyone and is secure and tamper-proof from all kinds of malicious actors. In other words, they wanted to avoid the mistakes of the past.

Little did they know though of Rick’s secret developer skills…

I opened the Flutter Bootcamp course yesterday after a while and discovered that the next challenge is to build a Bitcoin ticker app.

To make things a bit more challenging, I decided to build a Flutter app to track the prices of all the trading pairs on Loopring Exchange, which is a Decentralised Exchange (DEX).

Determining the OS and switching between Cupertino (iOS) and Material (Android) widgets

Since this module was a Boss Level Challenge there was not much new material introduced, so “short” article today (yay!).

One of the most important topics introduced is how to determine the operating system the app is running on, in order to switch between iOS-style and Android-style components.

Flutter provides a library of Cupertino widgets, which are essentially the iOS-style widgets, and all other widgets follow the Material Design style, most commonly seen on Android.

For example, on Android we have this dropdown menu, represented by the DropdownButton class.

DropdownButton

When you tap on the DropdownButton, the dropdown menu pops up and you tap on an item to make a selection.

DropdownButton menu

On iOS, we have a picker that looks like a dial, represented by the CupertinoPicker class in Flutter. Here, you just scroll on the dial to make a selection.

CupertinoPicker

But how can you detect which platform the app is running on, so you can show the appropriate widget?

We do this using the Platform class from the dart:io library.

We just need to import the Platform class, not the whole package, so we can make use of the show keyword.

import 'dart:io' show Platform;

Then we can can check Platform.isIOS or Platform.isAndroid to see which OS we’re on.

In my code, I use the ternary operator (X ? A : B) to choose the appropriate widget for each case.

Container buildTradingPairSelector() {
return Container(
...
child: Platform.isIOS ? IOSPicker(...) : AndroidDropdown(...),
);
}

IOSPicker and AndroidDropdown are just some custom widgets that I made for this app, containing all the code to build the CupertinoPicker and DropdownButton respectively.

I’ll get back to them in a bit.

I just need to give you a quick code overview first to put everything in context.

Code Overview

As usual, we start in the main.dart file. Nothing special here, just setting the MainScreen as our home screen.

Here’s the constants.dart and pubspec.yaml files too if you want to have a look.

In MainScreen, we begin by declaring some variables on top:

class _MainScreenState extends State<MainScreen> {
String selectedTradingPair = kInitialTradingPair;
LoopringService loopringService = LoopringService();
Future<MostRecentTrade> futureMostRecentTrade;
Future<CandlestickSeries> futureCandlestickSeries;
  • selectedTradingPair will start with our kInitialTradingPair constant and then will be set to whatever the user selects from the dropdown or picker.
  • loopringService will hold an instance of our LoopringService class that will make the requests to the Loopring API (more on this later).
  • futureMostRecentTrade will hold the future of our most recent trade data (the widget on top of the app)
  • futureCandlestickSeries will hold the future data for the candlesticks

A future represents the result of an asynchronous operation and can have two states: uncompleted or completed.

While we’re making a call to the API, these futures will be in the uncompleted state. When we get the response from the API, these futures will be in the completed state.

If you’re not familiar with futures, I cover them in detail together with asynchronous programming in the previous episode: I Am Rick (Episode 7): Coronavirus Tracker. (you can also have a quicker look at the docs here)

In Episode 7 we only had one call to the API, one future, one spinner and one error message alert.

In this episode, we’re making two parallel calls to the API, we have two futures, two spinners and two error message labels 😵

You’ll see how cool this is in a bit!

Moving on in MainScreen, we have a refresh method that calls the getMostRecentTrade and getCandlestickData methods of the LoopringService that query the Loopring API. We pass them the selectedTradingPair to query for the appropriate pair. We also set our future variables here to await for the results.

void refresh() {
futureMostRecentTrade =
loopringService.getMostRecentTrade(tradingPair: selectedTradingPair);
futureCandlestickSeries =
loopringService.getCandlestickData(tradingPair: selectedTradingPair);
}

The refresh method is called in initState() (when the app starts and the state is set for the first time), as well as in the onPickerChanged and onDropdownChanged callbacks of IOSPicker and AndroidDropdown, which trigger when the user selects a trading pair.

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

The build method of MainScreen looks like this:

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Rick\'s DEX'),
),
body: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
futureBuildMostRecentTrade(),
futureBuildTradingView(),
buildTradingPairSelector(),
buildPoweredBy(),
],
),
),
);
}

Besides the usual Scaffold, AppBar and SafeArea, we have a Column with four children. Each child is a method responsible for building one of the four main components of the app.

Back to the trading pair selectors

Now that you have a bigger picture of the code we can dive back into the dropdowns and pickers 😅

Here’s a more detailed version of the buildTradingPairSelector() method that I showed you before:

Container buildTradingPairSelector() {
return Container(
...
child: Platform.isIOS
? IOSPicker(
selectedTradingPair: selectedTradingPair,
onPickerChanged: (selectedItemIndex) {
setState(() {
selectedTradingPair = kTradingPairs[selectedItemIndex];
});
refresh();
},
)
: AndroidDropdown(
selectedTradingPair: selectedTradingPair,
onDropdownChanged: (value) {
setState(() {
selectedTradingPair = value;
});
refresh();
},
),
);
}

We pass them the selectedTradingPair variable in order for them to display the correctly selected trading pair, and we also get a callback when a user selects a new pair. We update the UI inside setState() with the new pair and call refresh() to get the relevant data from the API.

In IOSPicker’s build method I call this method to build the picker:

CupertinoPicker buildPicker() {
List<Text> pickerItems = [];
for (String tradingPair in kTradingPairs) {
pickerItems.add(
Text(
tradingPair,
style: kTextStyleTradingPair,
),
);
}
return CupertinoPicker(
scrollController: FixedExtentScrollController(
initialItem: kTradingPairs.indexOf(selectedTradingPair)),
children: pickerItems,
onSelectedItemChanged: (selectedItemIndex) {
onPickerChanged(selectedItemIndex);
},
itemExtent: 26.0,
backgroundColor: kColourBackgroundDark,
);
}

Basically I iterate through kTradingPairs (a constant List<String> containing all the trading pairs) and build a List of Text widgets. Then I pass this list to the children property of CupertinoPicker.

The scrollController property is used to set the initialItem that the picker will start with. It needs an index so we set it to the indexOf the selectedTradingPair in kTradingPairs. If we don’t set the scrollController, the picker will always start with the first item in the list. So for example, if we set kInitialTradingPair to ‘LRC-ETH’ instead of ‘LRC-USDT’, the chart will show the LRC-ETH candlesticks, but the picker will show LRC-USDT.

The onSelectedItemChanged callback is called every time the user picks an item. We get selectedItemIndex which is the index of the item selected and we pass it to the custom callback onPickerChanged that will make this index available to the buildTradingPairSelector() method that will set the state with the newly chosen selectedTradingPair, updating the UI, and then will call a refresh() to get fresh data from the API.

IOSPicker(
selectedTradingPair: selectedTradingPair,
onPickerChanged: (selectedItemIndex) {
setState(() {
selectedTradingPair = kTradingPairs[selectedItemIndex];
});
refresh();
},
)

The itemExtent property defines the height of each item in the picker.

The AndroidDropdown is a similar story. Inside the build method, I call this method to build the dropdown:

DropdownButton<String> buildDropdown() {
List<DropdownMenuItem<String>> dropdownMenuItems = [];
for (String tradingPair in kTradingPairs) {
dropdownMenuItems.add(
DropdownMenuItem(
child: Text(
tradingPair,
style: kTextStyleTradingPair,
),
value: tradingPair,
),
);
}
return DropdownButton<String>(
value: selectedTradingPair,
items: dropdownMenuItems,
onChanged: (value) {
onDropdownChanged(value);
},
dropdownColor: kColourBackgroundDark,
);
}

Here, instead of a List<Text>, we have a List<DropdownMenuItem<String>>. We iterate again over the kTradingPairs and add a DropdownMenuItem each time to the list, containing a Text as the child and the tradingPair String as the value. Then we set the DropdownButton’s items property with our list.

The value property takes the selectedTradingPair that we passed to the widget to show the initial item (similar to what we did with scrollController above).

The onChanged callback is called every time the user picks an item. We get a value this time so we can pass it directly to the custom callback onDropdownChanged that will make it available to the buildTradingPairSelector() method that will set the state with the newly chosen selectedTradingPair, updating the UI, and then will call a refresh() to get fresh data from the API.

AndroidDropdown(
selectedTradingPair: selectedTradingPair,
onDropdownChanged: (value) {
setState(() {
selectedTradingPair = value;
});
refresh();
},
),

And that’s it for the trading pair selectors!

The buildPoweredBy() method is too easy so I’m not gonna cover it. It just shows a container with the ‘Powered by Loopring.io’ label.

Next, I’ll cover the future components, futureBuildMostRecentTrade() and futureBuildTradingView(), but first let’s do a quick overview of the Loopring API.

The Loopring Exchange

This is what the Loopring Exchange looks like. It looks really cool and has a lot of components. I can’t imagine trying to build this in Flutter, at least not yet 😂

You can tap into a lot of this functionality through the Loopring API.

I’m only going to build two components:

  • one that will show the most recent trade (the top item of the Recent Trades list on the right)
  • one that will show the daily candlesticks from the last 20 days

For these components, you don’t need an API key.

The trade endpoint and the most recent trade component

I’m gonna start with the first component on top, which will show the most recent trade.

I built a MostRecentTrade model that will hold the data that we get back from the trade endpoint.

class MostRecentTrade {
final DateTime time;
final String direction;
final double price;
MostRecentTrade.fromJSON({Map<String, dynamic> json})
: time = DateTime.fromMillisecondsSinceEpoch(
int.parse(json['data']['trades'][0][0])),
direction = json['data']['trades'][0][2],
price = double.parse(json['data']['trades'][0][4]);
}

We need:

  • the time data to display the date and time
  • the direction data to make the price green or red depending on if it was a BUY or a SELL
  • the price data to display the price

In the LoopringService class, we have the getMostRecentTrade method that will call the trade endpoint.

The baseUrl is:

final baseUrl = 'https://api.loopring.io/api/v2/';

The parameters are:

  • market: the tradingPair parameter that comes from selectedTradingPair
  • limit: set to ‘1’ to fetch only the latest trade

If we get a successful response, we parse the JSON data into a MostRecentTrade object by using the fromJSON constructor, passing it the decoded JSON from jsonDecode(response.body).

To do all the above we need these two packages:

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

Now that we know what the request and parsing of MostRecentTrade looks like, we can go back to MainScreen to look at the futureBuildMostRecentTrade() method.

FutureBuilder<MostRecentTrade> futureBuildMostRecentTrade() {
return FutureBuilder<MostRecentTrade>(
future: futureMostRecentTrade,
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return SpinContainer(height: kHeightMostRecentTrade);
case ConnectionState.active:
case ConnectionState.done:
if (snapshot.hasData) {
return MostRecentTradeContainer(
selectedTradingPair: selectedTradingPair,
mostRecentTrade: snapshot.data,
);
} else if (snapshot.hasError) {
return ErrorContainer(
height: kHeightMostRecentTrade,
errorMessage: snapshot.error.toString(),
);
}
}
return Container();
},
);
}

This method returns a FutureBuilder<MostRecentTrade> widget, that will build an appropriate widget, depending on the ConnectionState of the snapshot.

If the ConnectionState is .none or .waiting we return a spinner using the SpinContainer wrapper widget that I build around the SpinKitWave widget from the flutter_spinkit package.

If the ConnectionState is .active or .done, we check if the snapshot.hasData or if the snapshot.hasError.

If the snapshot.hasData, we return a MostRecentTradeContainer that will display our component. We give it the snapshot.data for its mostRecentTrade property which is of type MostRecentTrade.

If the snapshot.hasError, we return an ErrorContainer, which is essentially a Container to display the error message. We set its errorMessage property to snapshot.error.toString().

I cover networking, JSON parsing, constructors, FutureBuilder, ConnectionState, spinners, Exceptions and error handling, date formatting, etc in a lot of detail in Episode 7, so do check it out if you’re unfamiliar with these concepts. This is mainly revision here.

The neat trick that I’m doing here is that I pass a constant height to all these widgets, kHeightMostRecentTrade, so the height will stay consistent, be it a spinner, the component, or an error message.

You’ll see why this is important as we combine the next future component.

The candlestick endpoint and the trading view component

There are two model classes associated with this component, CandlestickSeries and Candlestick.

The CandlestickSeries class has only one property, candlesticks, which is of type List<Candlestick>.

It also has a factory constructor that will parse the decoded json into a CandlestickSeries object, setting its candlesticks property.

class CandlestickSeries {
final List<Candlestick> candlesticks;
CandlestickSeries({this.candlesticks}); factory CandlestickSeries.fromJson({Map<String, dynamic> json}) {
List<Candlestick> candlestickSeries = [];
for (dynamic candlestickData in json['data']) {
candlestickSeries.add(
Candlestick(
open: double.parse(candlestickData[2]),
close: double.parse(candlestickData[3]),
high: double.parse(candlestickData[4]),
low: double.parse(candlestickData[5]),
volume: double.parse(candlestickData[7]),
),
);
}
return CandlestickSeries(candlesticks: candlestickSeries);
}

The Candlestick class represents a single candlestick and has the open, close, high, low and volume properties.

class Candlestick {
final double open;
final double close;
final double high;
final double low;
final double volume;
Candlestick({
this.open,
this.close,
this.high,
this.low,
this.volume,
});
}

Back in the LoopringService class, we have another method called getCandlestickData that hits the candlestick endpoint of the Loopring API.

Here the parameters are:

  • interval: The time interval of each candlestick, with possible values: 1min, 5min, 15min, 30min, 1hr, 2hr, 4hr, 12hr, 1d, 1w. I set it to ‘1d’ but you could also add another dropdown/picker to the interface so that the user can toggle that.
  • start: the starting time of the query in milliseconds. I use the DateTime.now() method to get the current date and time, then .subtract(days: 20) to go 20 days ago, then .millisecondsSinceEpoch to convert it to milliseconds.
  • end: the ending time of the query in milliseconds. I just use DateTime.now().millisecondsSinceEpoch here.
  • limit: the limit on the number of results. If the candlesticks between start and end are higher than this limit, only the ‘limit’ latest candlesticks will be returned.

Now that we know what CandlestickSeries is, we can go back to MainScreen, to look at the futureBuildTradingView() method.

FutureBuilder<CandlestickSeries> futureBuildTradingView() {
return FutureBuilder<CandlestickSeries>(
future: futureCandlestickSeries,
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return SpinContainer(height: calculateTradingViewHeight());
case ConnectionState.active:
case ConnectionState.done:
if (snapshot.hasData) {
return TradingView(
height: calculateTradingViewHeight(),
candlestickSeries: snapshot.data,
);
} else if (snapshot.hasError) {
return ErrorContainer(
height: calculateTradingViewHeight(),
errorMessage: snapshot.error.toString(),
);
}
}
return Container();
},
);
}

This is very similar to the futureBuildMostRecentTrade() method.

The SpinContainer and ErrorContainer are identical, with the difference being in what we pass them as height.

Adapting the TradingView height to the device size

Instead of passing a constant height to the SpinContainer, TradingView and ErrorContainer components, I pass them the result of the calculateTradingViewHeight() method from MainScreen.

double calculateTradingViewHeight() {
// full screen height
double height = MediaQuery.of(context).size.height;
// height without SafeArea
var padding = MediaQuery.of(context).padding;
height = height - padding.top - padding.bottom;
// height without widgets
for (double widgetHeight in kWidgetHeights) {
height = height - widgetHeight - kHeightMargin;
}
return height;
}

The purpose of this method is to calculate the remaining height available for the TradingView component. It’s assuming that all other components have a fixed height, which is specified in this section in constants.dart.

const kHeightMostRecentTrade = 90.0;
const kHeightTradingPair = 100.0;
const kHeightPoweredBy = 60.0;
const kWidgetHeights = [
kHeightMostRecentTrade,
kHeightTradingPair,
kHeightPoweredBy
];
const kHeightMargin = 40;

The TradingView height is then determined by the remaining height, depending on the device height. This should support a more consistent user interface across a wide range of devices.

You might have noticed from this picture that the TradingView is shorter on the Android device than the iOS device, but the layout and margins remain consistent.

By passing the same height to the SpinContainer, TradingView and ErrorContainer components we also ensure the height remains consistent across the lifecycle of the future.

Finally, to wrap up all the widgets, we have the TradingView widget.

class TradingView extends StatelessWidget {
final double height;
final CandlestickSeries candlestickSeries;
TradingView({
@required this.height,
@required this.candlestickSeries,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
color: kColourBackgroundDark,
padding: EdgeInsets.symmetric(horizontal: 2.0),
child: OHLCVGraph(
data: candlestickSeries.formatDataForOHLCVGraph(),
enableGridLines: true,
volumeProp: 0.1,
decreaseColor: kColourDecrease,
increaseColor: kColourIncrease,
lineWidth: 1.5,
labelPrefix: '',
),
);
}
}

It has the height and candlestickSeries properties, that we use to create an OHLCVGraph. This is a widget coming from the flutter_candlesticks package.

To format the data from our CandlestickSeries object to the format accepted by the OHLCVGraph, I wrote a formatDataForOHLCVGraph() method inside the CandlestickSeries class.

List<Map<String, double>> formatDataForOHLCVGraph() {
List<Map<String, double>> candlestickData = [];
for (Candlestick candlestick in candlesticks.reversed) {
candlestickData.add({
'open': candlestick.open,
'high': candlestick.high,
'low': candlestick.low,
'close': candlestick.close,
'volumeto': candlestick.volume,
});
}
return candlestickData;
}

One app, two requests, two spinners, two futures, two error labels

Putting everything together we see that we can have two spinners while each of the requests is being performed, and we can have two error labels. Either one can fail, but it doesn’t hurt the other component. And the height remains consistent across the whole experience 😎

Grab the final code here.

ALERT: Next episodes are out!

--

--