I Am Rick (Episode 7): Coronavirus Tracker

How to build a Flutter app with geolocation and APIs to track the spread of the virus in your country.

Alexandros Baramilis
Flutter Community
30 min readMar 22, 2020

--

Intro

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

or 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:

One of the best ways for me to learn something is to write about it, so I’m making this series with the notes that I take as I go through App Brewery’s Flutter Bootcamp. Publishing my notes and code also forces me to maintain a high standard and allows me to easily go back for a quick review or update. I’m keeping the Rick Grimes theme alive for as long as I can, because it just makes it so much more fun. Don’t worry, App Brewery’s bootcamp doesn’t have Rick in it, but I still highly recommend it if you want to learn Flutter. 😄

Some thoughts about the current crisis

I’ve oscillated between various emotional states over the past few weeks. From being concerned, to being sick of reading the word coronavirus everywhere to finally beginning to accept this new reality.

But there’s still something that doesn’t really make sense to me.

Why hasn’t the global economy shut down for say climate change, or air pollution?

From the WHO website:

Air pollution kills an estimated seven million people worldwide every year. WHO data shows that 9 out of 10 people breathe air containing high levels of pollutants.

Seven million people die every year from air pollution!

9 out of 10 people breathe highly polluted air!

And scientists have been warning us for decades about catastrophic, planet-altering, possibly irreversible climate change.

Did travelling get banned? Did we go into lockdown? Did we start working from home?

No. But we did it because of the flu. Yes, I know, the coronavirus is not the flu, it’s worse, but you get the point.

Just in case it’s not clear, I’m not against the measures being taken. I believe they are necessary to contain the virus so that healthcare systems are not overwhelmed.

I just think there is a big difference between the severity of the problems and the reactions from our governments.

It seems that somehow, more people (and whole species) dying over a longer period of time is more ok than fewer people (and mainly older ones) dying over a shorter period.

Or is it just a case of global procrastination? Of only dealing with what’s directly in front of us?

In any case, it is what it is. I sincerely hope everyone is dealing ok with it.

On a funnier note, out of all the stuff that I read, I thought this quote by Bryan Johnson sums it up well:

Covid-19 is like a surprise school exam, that your teacher told you would be coming. It was in the syllabus, the teacher reminded the class several times, and yet when it arrives, it’s abrupt chaos! It is tough being human.

I’m sure we’ll say the same when climate change hits us full force in the face.

Coronavirus Tracker

On one hand I had an App Brewery module building a weather app to teach geolocation, asynchronous programming, networking, APIs, etc.

On the other hand I had a pandemic.

And I’m making a Walking Dead themed series.

So the answer was pretty obvious for this episode. An app that will use your location to show you the virus statistics in your country or province.

If you’re into The Walking Dead, you can think of this as the virus that gave us our beloved Walkers. Maybe if they had this app, they could’ve contained it better.

The API I’ll be using is coronavirus-tracker-api by ExpDev07, which is based on data provided by Johns Hopkins.

Geolocation in Flutter

Geolocation in Flutter is super simple thanks to Flutter packages, specifically the geolocator package.

You don’t need to write any platform-specific code for iOS or Android, which is a blessing. 😁

But you do need to set some permissions first.

For iOS:

  • Go to: ios → Runner → Info.plist
  • Just under the <dict> tag, add:
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs to access your location in order to show the coronavirus metrics in your area.</string>

This is the message that will appear here:

You should modify this string to an appropriate message for your app.

For Android:

  • Go to: android → app → src → main → AndroidManifest.xml
  • Just under the <manifest> tag, add:
- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

I chose ACCESS_COARSE_LOCATION because I don’t need high location accuracy in this app. If you need higher accuracy, choose ACCESS_FINE_LOCATION.

Stop and run the app again for the changes to take effect.

To test the location service, I made a RickButton, modifying the BottomButton I had in Episode 6. (the power of modularising your code!)

body: SafeArea(
child: Center(
child: RickButton(
label: 'Get Location',
onPressed: getLocation,
),
),
),

When we tap the button, we call the getLocation method:

void getLocation() async {
Position position = await Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.lowest);
print(position);
}

I set the desiredAccuracy to lowest because we don’t need high accuracy in this app and the lower the accuracy the more battery you save. You can check the detailed accuracy values here. It’s still pretty good for our purposes at the lowest setting, within a distance of 3000m on iOS and 500m on Android.

When the location is determined we get a print out in the console:

flutter: Lat: 51.522796, Long: -0.085452

We can manually set the location that we get from the simulators:

For iOS:

  • Simulator → Debug → Location

For Android:

  • Tap on the three dots button on the Emulator side menu and go to the Location tab.

Now on to some theory. If you’re familiar with asynchronous programming in Dart, feel free to skip ahead.

Asynchronous programming in Dart: Futures, Async & Await

Synchronous vs Asynchronous programming

So far we’ve been doing synchronous programming, which is when tasks are executed in the order specified.

Say we have these three tasks:

import 'dart:io';void main() {
performTasks();
}
void performTasks() {
task1();
task2();
task3();
}
void task1() {
String result = 'task 1 data';
print('Task 1 complete');
}
void task2() {
String result = 'task 2 data';
print('Task 2 complete');
}
void task3() {
String result = 'task 3 data';
print('Task 3 complete');
}

We will get this print out:

Task 1 complete
Task 2 complete
Task 3 complete

But let’s say step 2 takes a long time.

You don’t want to wait all day for it to finish. You want to do other things in the meantime. This is when asynchronous programming comes in.

Asynchronous programming is all about multitasking. Instead of waiting for step 2 to complete, we can start running step 2, then execute step 3, then at some point later when step 2 completes, do something with its output, like print it out.

In this scenario we would get the task 3 output before the task 2 output, without changing the order of the calls in performTasks().

Task 1 complete
Task 3 complete
Task 2 complete

Note: Running “scratch” code in Android Studio

1) Create a new Dart file inside the main project directory, ex. ‘scratch.dart’

2) Type your code (don’t forget to include a void main() {} function)

3) Right click on the file and select ‘Run scratch.dart’

This runs the file by itself, isolated from the rest of the project, so you don’t have to write any UI code, etc.

It basically works like dartpad but can execute code using all the Dart and Flutter libraries and takes advantage of all the power of Android Studio.

Adding delay with sleep() or Future.delayed()

A handy function if you want to add some delay in the execution of your code is the sleep function.

void task2() {
Duration duration = Duration(seconds: 3);
sleep(duration); String result = 'task 2 data';
print('Task 2 complete');
}

Now if we run the code, we’ll get:

Task 1 complete(... pause for 3 seconds ...)Task 2 complete
Task 3 complete

The sleep method is synchronous.

We can also do the same with an asynchronous method, Future.delayed().

void task2() {
Duration duration = Duration(seconds: 3);
Future.delayed(duration, () {
String result = 'task 2 data';
print('Task 2 complete');
});
}

The Future.delayed() method takes a Duration parameter, as well as a function to execute after the delay.

Now if we run the code, we get:

Task 1 complete
Task 3 complete
(... pause for 3 seconds ...)Task 2 complete

Because Future.delayed() is an asynchronous function, the code will go on and execute task 3 and when task 2 completes (after the 3 second delay) it will print out its result.

The async and await keywords

Now, what if the code for task 3, depended on the output of task 2?

void performTasks() {
task1();
String task2Result = task2();
task3(task2Result);
}
void task1() {
String result = 'task 1 data';
print('Task 1 complete');
}
String task2() {
Duration duration = Duration(seconds: 3);
String result; Future.delayed(duration, () {
result = 'task 2 data';
print('Task 2 complete');
});
return result;
}
void task3(String task2Data) {
String result = 'task 3 data';
print('Task 3 complete with $task2Data');
}

This would print:

Task 1 complete
Task 3 complete with null
(... pause for 3 seconds ...)Task 2 complete

Because Future.delayed() is asynchronous, the execution will continue and task2() will return a null value for task2Result, because the result inside task2() hasn’t been set to ‘task 2 data’ yet. Then the null value will be passed to task3() which will print ‘Task 3 complete with null’. When task2() finally completes, it will print ‘Task 2 complete’.

This is not good, because we actually need the result of task2() to be used in task3().

We need to tell the code to wait for task2() to finish before moving on to task3().

For this, we use the async and await keywords.

void performTasks() async {
task1();
String task2Result = await task2();
task3(task2Result);
}
void task1() {
String result = 'task 1 data';
print('Task 1 complete');
}
Future<String> task2() async {
Duration duration = Duration(seconds: 3);
String result; await Future.delayed(duration, () {
result = 'task 2 data';
print('Task 2 complete');
});
return result;
}
void task3(String task2Data) {
String result = 'task 3 data';
print('Task 3 complete with $task2Data');
}

We need to mark the method with the async keyword, in order to use the await keyword inside.

By putting the await keyword before the method, we tell the code to wait for the method to finish before moving on.

So our output this time is:

Task 1 complete(... pause for 3 seconds ...)Task 2 complete
Task 3 complete with task 2 data

The code will pause inside task2() waiting for Future.delayed() to complete, then it will assign the result to be returned. Inside performTasks() the code will also wait for task2() to complete, before assigning its output to task2Result. Then task3() will execute with the appropriate data.

It’s also worth noting that task2() will be an instance of Future<String> until it is completed. When it’s completed it will materialize into a String and assigned to task2Result.

What are Dart futures?

A future is an instance of the Future.

😮

This codelab has a good summary of what futures are:

A future (lower case “f”) is an instance of the Future (capitalized “F”) class. A future represents the result of an asynchronous operation, and can have two states: uncompleted or completed.

Uncompleted
When you call an asynchronous function, it returns an uncompleted future. That future is waiting for the function’s asynchronous operation to finish or to throw an error.

Completed
If the asynchronous operation succeeds, the future completes with a value. Otherwise it completes with an error.

Completing with a value
A future of type Future<T> completes with a value of type T. For example, a future with type Future<String> produces a string value. If a future doesn’t produce a usable value, then the future’s type is Future<void>.

Completing with an error
If the asynchronous operation performed by the function fails for any reason, the future completes with an error.

We can either specify a generic, dynamic Future that can take any type, or limit it to a certain type by specifying Future<T> where T is the type, ex Future<String>.

So now you can fully understand the piece of code that we had before:

void getLocation() async {
Position position = await Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.lowest);
print(position);
}

We await until the hardware gets our current location with our desired accuracy and then we print our position.

Now, what if we wanted to get the location as soon as the user opens the app, without having to press a button?

In order to implement that functionality, we need to learn more about widget lifecycles.

Widget Lifecycles

The Lifecycle of Stateless Widgets

The lifecycle of stateless widgets is simple. They get built and — because they are stateless and immutable — when something needs to change, they get destroyed and then rebuilt. So the only important lifecycle method for stateless widgets is the build method.

The Lifecycle of Stateful Widgets

The lifecycle of stateful widgets is more complex because they hold a state.

Some of the most useful lifecycle methods of stateful widgets are:

  • initState gets triggered when the state gets initialised, which is as soon as the widget gets inserted into the tree. It is only called once in the lifetime of a stateful widget.
void initState() {
}
  • build get triggered when the widget needs to be built. It will also get called whenever we make a change and call setState(), which triggers a rebuild.
Widget build(BuildContext context) {
return null;
}
  • deactivate gets called when the widget is destroyed, for example when we pop the current screen from the navigator.
void deactivate() {
}

So, to get the location as soon as the user opens the app, we just need to override the initState() method and call our getLocation() method from there.

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

We could’ve called it from the build() method too, but that would call the getLocation() method every time something changes on screen and triggers the rebuild method, so it would be a waste of resources.

Dart Exception Handling and Null-Aware Operators

Before we get to tackling APIs, we need to learn more about what to do when things go wrong, which is quite often.

The try catch pattern

The try catch pattern is often use in programming to catch errors that the code throws.

try {
Position position = await Geolocator()
.getCurrentPosition(desiredAccuracy: LocationAccuracy.lowest);
print('Lat: ${position.latitude}, Lon: ${position.longitude}');
} catch (e) {
print('Failed to get current location: $e');
print('Printing default location.');
print('Lat: 51.522796, Lon: -0.085452');
}

First, we try to get the position. If it’s successful, we print it out. If not, we catch the error/exception (e) and we print it out. We can also specify code on how to handle the error so that the user experience is not impacted too much, for example by providing some default values.

The throw keyword

The throw keyword is used to throw an exception like:

throw 'Oops, you screwed up!';

In our example, we can inspect the getCurrentPosition() method of the Geolocator to see what possible exceptions it might throw.

We can cmd + click (on Mac) on the method, which takes us to its definition.

There we can see:

if (permission == PermissionStatus.granted) {
// ... some code ...
} else {
_handleInvalidPermissions(permission);
}

Doing the same for _handleInvalidPermissions, we can see where the exception is thrown:

void _handleInvalidPermissions(PermissionStatus permission) {
if (permission == PermissionStatus.denied) {
throw PlatformException(
code: 'PERMISSION_DENIED',
message: 'Access to location data denied',
details: null);
}
}

Stricter programming languages will force you to wrap any calls of functions that have throw in them in a try catch statement, but Dart is bit more relaxed here.

However, if you want to write safer code, it’s good to check the code of any functions that you call for the throw keyword (cmd + click to see the code and cmd + F to search for throw) and wrap the calls in try catch statements.

Null-aware operators

There are various null-aware operators in Dart, but one quite common one is:

??

a = value ?? 0;

Which means that if value is null, a will be set to 0. Otherwise, it will be set to value.

This is handy when we need to provide some default values in case our value is null, for example because our code failed.

The Coronavirus Tracker API

APIs give you a lot of power in building applications. Imagine trying to build a weather app without an API. How would you even get all this data? Using an API is so much simpler. If you need a small primer on APIs you can watch this video by Codeacademy.

There are various ways to discover APIs. I usually start with a Google search, then go through an API directory such as ProgrammableWeb. They list over 20,000 APIs!

The API I’ll be using in this app is the coronavirus-tracker-api by ExpDev07, which is based on data kindly provided by Johns Hopkins.

It’s a pretty simple API, doesn’t need any keys and only has 2 GET endpoints, latest, for the global data, and locations, for the local data.

It’s also a very new API and it’s structure is changing frequently even as I write this, so bear in mind it might not be the same when you read this. Just go through the docs to check.

The base URL is: https://coronavirus-tracker-api.herokuapp.com

Pasting the data in a JSON viewer we can better visualise its structure:

GET /v2/latest

Gets the latest amount of total confirmed cases, deaths, and recoveries.

GET /v2/locations

Gets all locations.

There are also options for filtering locations:

GET /v2/locations?country_code=US

Filters locations by country, based on ISO 3166–1 alpha-2 country codes.

GET /v2/locations?country_code=US&timelines=true

Includes timelines, useful for plotting historical data.

GET /v2/locations/39

Gets a specific location by id and includes timelines by default.

Choosing what data to use

This is a good point to decide what data we’re interested in and what the UI will look like.

I would like to have:

  • A date: can just use today’s date
  • The global data from latest: confirmed, deaths and recovered.
  • The local data from locations: country and confirmed, deaths and recovered from the latest field.

The timelines are also interesting if you want to plot the curve of the virus spread, but I’m gonna skip it for this episode.

The location is going to be determined using the user’s current location, but I would also like to have a text input field where you can search for any country.

The User Interface

Using mock data, I built the UI below.

This is the first screen, called StatsScreen.

You can find the final code here if you want the full picture from now. Otherwise, just follow along.

Basically, inside a Column, I have these children:

Text(coronavirusService.date, style: kTextStyleDate),
Text(
coronavirusService.locationLabel,
style: kTextStyleLocationLabel,
),
StackPie(
totalNumber: coronavirusService.totalNumber,
sickNumber: coronavirusService.sickNumber,
recoveredNumber: coronavirusService.recoveredNumber,
deadNumber: coronavirusService.deadNumber,
),
Stats(
sickNumber: coronavirusService.sickNumber,
recoveredNumber: coronavirusService.recoveredNumber,
deadNumber: coronavirusService.deadNumber,
sickPercentage: coronavirusService.sickPercentage,
recoveredPercentage: coronavirusService.recoveredPercentage,
deadPercentage: coronavirusService.deadPercentage,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
ActionButton(icon: Icons.search, onPressed: () {}),
ActionButton(icon: Icons.near_me, onPressed: () {}),
ActionButton(icon: Icons.language, onPressed: () {}),
],
),

Ignore the coronavirusService here. It’s just a mock service that I used while designing the UI.

There are three widgets of interest here: StackPie, Stats and ActionButton.

StackPie 🍰

StackPie looks like this:

Stack(
alignment: Alignment(0, -0.14),
children: <Widget>[
Column(
children: <Widget>[
Text('Total:', style: kTextStyleTotalLabel),
SizedBox(height: 12),
Text(
kNumberFormat.format(totalNumber),
style: kTextStyleTotalNumber,
),
],
),
PieChart(
PieChartData(
startDegreeOffset: -90,
borderData: FlBorderData(show: false),
sections: [
PieChartSectionData(
value: sickNumber.toDouble(),
color: kColourPieSick,
radius: radius,
showTitle: showTitles,
),
PieChartSectionData(
value: recoveredNumber.toDouble(),
color: kColourPieRecovered,
radius: radius,
showTitle: showTitles,
),
PieChartSectionData(
value: deadNumber.toDouble(),
color: kColourPieDead,
radius: radius,
showTitle: showTitles,
),
],
),
),
],
);

I’m using a Stack class to overlap a Column and a PieChart.

The PieChart comes from the FL Chart package and you can read its docs here.

The Column has the text that shows the ‘Total:’ label and the total number of cases, in the middle of the PieChart.

I’m using the alignment property of Stack to align the elements. I set it to Alignment(0, -0.14), which means their centres are aligned, with a vertical offset to bring the centre of the number to the centre of the pie. I you know of a more robust method to do this, please comment!

Stats 🔢

The Stats widget looks like this:

Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
StatsRow(
colour: kColourPieSick,
label: 'Sick',
number: sickNumber,
percentage: sickPercentage,
),
StatsRow(
colour: kColourPieRecovered,
label: 'Recovered',
number: recoveredNumber,
percentage: recoveredPercentage,
),
StatsRow(
colour: kColourPieDead,
label: 'Dead',
number: deadNumber,
percentage: deadPercentage,
),
],
),
],
);

It’s a Row that has a Column inside with three StatsRow children.

StatsRow

StatsRow represents a row of the Stats widget.

Row(
children: <Widget>[
ColourBox(colour: colour),
Text('$label:', style: kTextStyleStats),
SizedBox(width: 4),
Text('${kNumberFormat.format(number)}', style: kTextStyleStats),
SizedBox(width: 4),
Text('(${percentage.toStringAsFixed(2)}%)',
style: kTextStyleStats,
),
],
);

I’m using the NumberFormat class from the intl package to format the numbers so they show like: 214,910 instead of 214910.

I set it back in the constants.dart file to:

final kNumberFormat = NumberFormat('#,##0');

And the toStringAsFixed method to round the percentages to the nearest two decimal places.

The ColourBox is just a simple coloured Container:

Container(
margin: EdgeInsets.fromLTRB(2, 0, 8, 2),
width: 12,
height: 12,
color: colour,
);

ActionButton 🔴

The ActionButton is just a simple button based on RawMaterialButton, with an Icon as a child and a circular border by setting the shape property to CircleBorder().

RawMaterialButton(
onPressed: onPressed,
child: Icon(
icon,
color: Colors.white,
size: 32.0,
),
shape: CircleBorder(),
elevation: 0,
fillColor: kColourPrimary,
padding: EdgeInsets.all(16.0),
);

Networking in Flutter

In this section I’m going to fetch the global data from the latest endpoint, update the UI and create a nice UX with a spinner and an error alert with a retry button.

I will start by following this Flutter cookbook that uses the http package to simplify networking and then I will expand on it.

The CoronavirusService class

I wrote a CoronavirusService class that will deal with all the networking in this app.

import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:iamrick/models/coronavirus_data.dart';
class CoronavirusService {
final baseUrl = 'https://coronavirus-tracker-api.herokuapp.com/v2/';
Future<CoronavirusData> getLatest() async {
final response = await http.get(baseUrl + 'latest');
if (response.statusCode == 200) {
return CoronavirusData.fromLatest(json.decode(response.body));
} else {
throw Exception('Failed to load coronavirus data.');
}
}
}

By importing a package and adding the as keyword, followed by another keyword of our choice (ex. http), we can make use of our chosen keyword inside the code, like in: http.get(baseUrl + 'latest'); This way we know that this method comes from the http package.

The getLatest() method queries the latest endpoint and returns a future as the get method is an async method. This future is of type Future<CoronavirusData>, where CoronavirusData is a model object that I created (more on this later).

So we await the return of the get method before we proceed to deal with the response. The response object is an instance of the Response class. We can check the statusCode property to see if the request was successful. Here’s a list of all status codes and their meanings.

If the request was not successful, we throw an Exception.

If it was successful, we decode the body of the response (response.body), using the json.decode() method from the dart:convert package and we pass it to the fromLatest() factory constructor of CoronavirusData.

The CoronavirusData model

import 'package:intl/intl.dart';class CoronavirusData {
final String date;
final String locationLabel;
final int totalNumber;
final int recoveredNumber;
final int deadNumber;
final int sickNumber;
final double sickPercentage;
final double recoveredPercentage;
final double deadPercentage;
CoronavirusData(
{this.date,
this.locationLabel,
this.totalNumber,
this.recoveredNumber,
this.deadNumber,
this.sickNumber,
this.sickPercentage,
this.recoveredPercentage,
this.deadPercentage});
factory CoronavirusData.fromLatest(Map<String, dynamic> json) {
int totalNumber = json['latest']['confirmed'];
int recoveredNumber = json['latest']['recovered'];
int deadNumber = json['latest']['deaths'];
int sickNumber = totalNumber - recoveredNumber - deadNumber;
return CoronavirusData(
date: DateFormat('EEEE d MMMM y').format(DateTime.now()),
locationLabel: 'Global',
totalNumber: totalNumber,
recoveredNumber: recoveredNumber,
deadNumber: deadNumber,
sickNumber: sickNumber,
sickPercentage: sickNumber * 100.0 / totalNumber,
recoveredPercentage: recoveredNumber * 100.0 / totalNumber,
deadPercentage: deadNumber * 100.0 / totalNumber,
);
}
}

If the docs on constructors didn’t do it for you, there is a very detailed article on constructors here.

Long story short, by using a factory constructor in this case, we can write code inside the curly braces and use the return statement to return a new CoronavirusData instance, things that are not allowed in a regular constructor. This way we can do the complex formatting that we want to do here and create a new instance of CoronavirusData in one go.

Another option would be to use a named constructor, but then we wouldn’t be able to declare variables inside the constructor and reuse them to simplify the code. So it would end up looking like this:

CoronavirusData.fromLatest(Map<String, dynamic> json)
: date = DateFormat('EEEE d MMMM y').format(DateTime.now()),
locationLabel = 'Global',
totalNumber = json['latest']['confirmed'],
recoveredNumber = json['latest']['recovered'],
deadNumber = json['latest']['deaths'],
sickNumber = json['latest']['confirmed'] -
json['latest']['recovered'] -
json['latest']['deaths'],
sickPercentage =
json['latest']['deaths'] * 100.0 / json['latest']['confirmed'],
recoveredPercentage =
json['latest']['recovered'] * 100.0 / json['latest']['confirmed'],
deadPercentage = (json['latest']['confirmed'] -
json['latest']['recovered'] -
json['latest']['deaths']) *
100.0 /
json['latest']['confirmed'];

I think the former is more readable.

Oh and for the date I used DateTime’s now() method to get the current time and date and then I formatted it using DateFormat(‘EEEE d MMMM y’) from the intl package. This translates to a date of: ‘Weekday Day Month Year’, like ‘Friday 20 March 2020’.

Putting everything together in StatsScreen

Now in StatsScreen, we add two new variables, a CoronavirusService and a Future<CoronavirusData>, since we don’t have the data yet.

class _StatsScreenState extends State<StatsScreen> {
CoronavirusService coronavirusService = CoronavirusService();
Future<CoronavirusData> futureCoronavirusData;
@override
void initState() {
super.initState();
futureCoronavirusData = coronavirusService.getLatest();
}

Inside initState() we set the the futureCoronavirusData by calling getLatest().

Inside the Scaffold body, I replaced the Column we had before with a FutureBuilder, a widget that builds itself based on the latest snapshot of interaction with a Future.

body: SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 20, horizontal: 10),
child: FutureBuilder<CoronavirusData>(
future: futureCoronavirusData,
builder: (context, snapshot) {
if (snapshot.hasData) {
return dataColumn(coronavirusData: snapshot.data);
} else if (snapshot.hasError) {
return errorAlert(errorMessage: snapshot.error.toString());
}
return SpinKitPulse(color: kColourPrimary, size: 80);
},
),
),
),

The FutureBuilder class has a future property that needs to be set to the Future object that we are “listening” to, in this case futureCoronavirusData.

Then there is the builder property that takes a function with a context and a snapshot as parameters.

This snapshot parameter is particularly important here and there are three properties that we need to pay attention to:

1. connectionState

A ConnectionState enum with the following cases:

  • none (not connected to any asynchronous computation)
  • waiting (connected and awaiting interaction)
  • active (connected, interacted, but not done yet)
  • done (connected to a terminated asynchronous computation)

So in our example, the snapshot will start out as waiting, while we wait for the response from the API, and when we get the response it will change to done. The none case is when the future parameter is null, and the active case is when we are listening to a stream which has returned at least one value but is not done yet.

The connectionState is not used in the above example but you will see soon why it’s important.

2. hasData

If our future object returns with data, this will be set to true, if not, it will be false.

We can also tap into the snapshot’s data property.

3. hasError

If there was an error, this will be set to true and we can also tap into the snapshot’s error property.

In the above example, we are checking first to see if there are any data. If there are, we pass it to the dataColumn method, which just return the Column that we had in the beginning of the UI. I just moved it into a method to make the code clearer.

So if there are data, the UI will be rendered normally.

If there are no data and there is an error, an alert popup will show, based on this function here:

AlertDialog errorAlert({String errorMessage}) {
return AlertDialog(
backgroundColor: kColourAlertBackground,
title: Text('Error', style: kTextStyleAlertTitle),
content: Text(errorMessage, style: kTextStyleAlertText),
actions: <Widget>[
FlatButton(
padding: EdgeInsets.only(right: 18.0),
child: Text('Retry', style: kTextStyleAlertButton),
onPressed: () {
setState(() {
futureCoronavirusData = coronavirusService.getLatest();
});
},

),
],
);
}

And if there is no data and no error, like when we first initialise the state, we get a nice spinner from Flutter Spinkit (docs).

To test out the spinner you can just comment out the call to getLatest() in initState().

The retry button

So we get an error. What happens when we tap on the retry button?

To simulate a temporary error, I added these lines in CoronavirusService:

String endpoint = 'latepst';Future<CoronavirusData> getLatest() async {
final response = await http.get(baseUrl + endpoint);
endpoint = 'latest';

It will start with a mistyped endpoint for the first call, but if we call getLatest() again, the mistake will have been corrected.

So we will get the popup error, we’ll tap on retry and the correct request will be made and it will be successful.

There’s only one problem.

The spinner won’t show when we tap on retry. The error alert will stay on and when we get the data back it will immediately change to the stats UI.

This is not great UX.

It took me a while to figure this out and this is why I wrote earlier that the connectionState property is important.

Just as reminder I’m pasting again the if statement we’re dealing with:

if (snapshot.hasData) {
return dataColumn(coronavirusData: snapshot.data);
} else if (snapshot.hasError) {
return errorAlert(errorMessage: snapshot.error.toString());
}
return SpinKitPulse(color: kColourPrimary, size: 80);
  • When we first initState(), snapshot has the following properties:
ConnectionState.waiting, hasData: false, hasError: false

Because hasData and hasError are false, we get the spinner.

  • Then we get the error because of the mistype endpoint.
ConnectionState.done, hasData: false, hasError: true

Because hasData is false and hasError is true, we get the error alert.

  • Then we tap on retry:
ConnectionState.waiting, hasData: false, hasError: true

The connectionState has flipped back to waiting, but the hasError is still true.

This is why the error alert is still on and we’re not seeing the spinner.

To fix this, we need to improve our if statement to take into account the connectionState.

switch (snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return SpinKitPulse(color: kColourPrimary, size: 80);
case ConnectionState.active:
case ConnectionState.done:
if (snapshot.hasData) {
return dataColumn(coronavirusData: snapshot.data);
} else if (snapshot.hasError) {
return errorAlert(errorMessage: snapshot.error.toString());
}
}
return Container(); // just because the compiler complains.

Now we get the spinner → the error appears → we tap on retry → we get the spinner again → then we get the data. 😅

Using location to get local data

So far, we’ve just used the /latest endpoint to get the global data, which was quite simple.

In this section we’ll use the geolocator package to get the user’s location and then use the /locations endpoint to get the data from the country that corresponds to the user’s location.

The most difficult part here is matching the user’s location to an entry in the locations list that is returned from the API.

But it was made easier this morning by a change in the API where the whole country data is included in the latest field of the response from the filtered by country code list. So it makes things a lot easier for countries that have many locations, like in the US for example.

https://coronavirus-tracker-api.herokuapp.com/v2/locations?country_code=US

I switched to JSON Viewer Awesome here 😛

So if we’re only interested in country data, the process is quite simple.

But first of all, back in StatsScreen, I reformatted the code like this:

I created an enum for the different types of location sources that we have. Each type corresponds to one of the buttons at the bottom.

enum LocationSource { Global, Local, Search }

And a variable to keep track of the current location source. Setting it to Local so when the app starts we get the Local data first.

class _StatsScreenState extends State<StatsScreen> {
Future<CoronavirusData> futureCoronavirusData;
LocationSource locationSource = LocationSource.Local;

Then during initState() we call a getData() method that will do a switch on the location source and then call the appropriate method.

@override
void initState() {
super.initState();
getData();
}
void getData() async {
switch (locationSource) {
case LocationSource.Global:
futureCoronavirusData = CoronavirusService().getLatestData();
break;
case LocationSource.Local:
Placemark placemark = await LocationService().getPlacemark();
setState(() {
futureCoronavirusData = CoronavirusService().getLocationData(placemark);
});
break;
case LocationSource.Search:
// TODO: Handle this case.
break;
}
}

Also, in the onPressed method of the ‘Retry’ button of the error alert we just call setState() and getData().

onPressed: () {
setState(() {
getData();
});
},

And in the action buttons at the bottom, we call setState() and inside we set the respective locations source and call getData().

ActionButton(
icon: Icons.search,
onPressed: () {
// TODO: Handle this case.
},
),
ActionButton(
icon: Icons.near_me,
onPressed: () {
setState(() {
locationSource = LocationSource.Local;
getData();
});
},
),
ActionButton(
icon: Icons.language,
onPressed: () {
setState(() {
locationSource = LocationSource.Global;
getData();
});
},
),

So now we can switch between location sources and get the relevant data.

Back to the LocationSource.Local case of the switch statement:

case LocationSource.Local:
Placemark placemark = await LocationService().getPlacemark();
setState(() {
futureCoronavirusData = CoronavirusService().getLocationData(placemark);
});
break;

First we call the getPlacemark() method of the LocationService to get a placemark (an object that represents our current location with some extra data). Because this is an async function that needs some time to complete, we must call setState() when it returns to update the UI with the location data that we get back from the getLocationData() method of the CoronavirusService.

That’s it from the StatsScreen.

The LocationService looks like this:

class LocationService {
Future<Position> getPosition() async {
try {
return await Geolocator()
.getCurrentPosition(desiredAccuracy: LocationAccuracy.lowest);
} catch (e) {
throw Exception('Failed to get position: $e');
}
}
Future<Placemark> getPlacemark() async {
Position position = await getPosition();
try {
List<Placemark> placemarks =
await Geolocator().placemarkFromPosition(position);
return placemarks[0];
} catch (e) {
throw Exception('Failed to get placemark: $e');
}
}
}

In the getPlacemark() method, we try to get the position first using the method that we wrote earlier and when we have it we try to get the placemark. It actually returns a list of placemarks so we just return the first item, or throw an exception if we fail.

In the CoronavirusService class, we call a getLocationData() method that is almost identical to the getLatestData() that we have for the global data.

class CoronavirusService {
final baseUrl = 'https://coronavirus-tracker-api.herokuapp.com/v2/';
Future<CoronavirusData> getLatestData() async {
final response = await http.get(baseUrl + 'latest');
if (response.statusCode == 200) {
return CoronavirusData.formatted(
json: jsonDecode(response.body),
country: 'Global',
);

} else {
throw Exception('Failed to load latest coronavirus data.');
}
}
Future<CoronavirusData> getLocationData(Placemark placemark) async {
final response = await http
.get(baseUrl + 'locations?country_code=${placemark.isoCountryCode}');
if (response.statusCode == 200) {
return CoronavirusData.formatted(
json: jsonDecode(response.body),
country: placemark.country,
);

} else {
throw Exception('Failed to load local coronavirus data.');
}
}
}

The difference here is that we’re passing it a placemark and using its property isoCountryCode to filter the locations by making this request:

.get(baseUrl + 'locations?country_code=${placemark.isoCountryCode}')

Also, when we call CoronavirusData.formatted(), we pass it the placemark.country property, to display the country name, instead of ‘Global’.

And in the CoronavirusData class, this is the modified version of the factory constructor we made earlier.

factory CoronavirusData.formatted({
Map<String, dynamic> json,
String country,
String province,
}) {
int totalNumber = json['latest']['confirmed'];
int recoveredNumber = json['latest']['recovered'];
int deadNumber = json['latest']['deaths'];
if (totalNumber == 0) {
throw Exception('No confirmed cases in your area.');
}

int sickNumber = totalNumber - recoveredNumber - deadNumber;
return CoronavirusData(
date: DateFormat('EEEE d MMMM y').format(DateTime.now()),
locationLabel: province == null ? country : '$country, $province',
totalNumber: totalNumber,
recoveredNumber: recoveredNumber,
deadNumber: deadNumber,
sickNumber: sickNumber,
sickPercentage: sickNumber * 100.0 / totalNumber,
recoveredPercentage: recoveredNumber * 100.0 / totalNumber,
deadPercentage: deadNumber * 100.0 / totalNumber,
);
}

I added an Exception if the totalNumber is zero so we get an alert instead of a broken UI (PieChart doesn’t like zero data) and for the locationLabel we check if the province String was provided to include it in the name.

Now we can run the app using a simulated location anywhere in the world and get the relevant country’s data.

I’m getting coordinates by opening Google Maps and clicking anywhere on the map and then going to the iOS Simulator → Debug → Location → Custom Location… and pasting the coordinates there. Then hot restart to reset the state.

Let’s try it.

Searching for other countries

Time to implement the final functionality, which is to search for other countries.

Back in StatsScreen, I added the implementation for the search ActionButton:

ActionButton(
icon: Icons.search,
onPressed: () async {
var countryCode = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => SearchScreen()),
);
setState(() {
locationSource = LocationSource.Search;
getData(countryCode: countryCode);
});
},
),

Here, I added the async keyword for the onPressed() callback, because we will navigate to the search screen and we will wait for a response from there that might come back at any time. So we also need to add the await keyword before the call to Navigator.push().

So the flow is: we’ll navigate to a new SearchScreen(), grab a countryCode, then come back and inside setState() set the locationSource to LocationSource.Search and call the getData() method, which I modified to now accept an optional countryCode.

In SearchScreen, I have the same base layout as StatsScreen, but the child of the Padding is a TextField widget.

TextField(
style: kTextStyleTextField,
decoration: kInputDecorationTextField,
cursorColor: kColourPrimary,
autofocus: true,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.search,
onChanged: (value) {
countryName = value;
},
onEditingComplete: () {
String countryCode = Countries().resolveCode(countryName);
if (countryCode != null) {
Navigator.pop(context, countryCode);
} else {
showErrorAlert(
message: 'No countries match.',
onRetry: () {
Navigator.of(context).pop();
});
}
},
),
  • The style property enables us to customise the style of the text inside the TextField.
  • The decoration property enables us to customise the style of the TextField itself.
  • The cursorColor allows us to set the colour of the blinking cursor when the TextField is enabled.
  • If we set the autofocus property to true, the TextField will be automatically enabled (and the keyboard will appear) when we navigate to SearchScreen.
  • By setting the textCapitalization to TextCapitalization.words we can have the first letter of each word capitalised.
  • By setting textInputAction to TextInputAction.search we can have set the action button of the keyboard to a ‘search’ button.

Unfortunately, I didn’t find a way to set the colour of the search button. I opened a question in StackOverflow. If anyone knows how to do it, please help!

For reference, the kInputDecorationTextField constant that I set the decoration property to looks like this: (in the constants.dart file)

final kInputDecorationTextField = InputDecoration(
hintText: 'Enter country name:',
hintStyle: kTextStyleTextFieldHint,
filled: true,
fillColor: Colors.lightGreen[300],
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide.none,
),
);

It’s an InputDecoration widget, where I set the following properties:

  • hintText is the text that will show in the TextField when it’s empty.
  • hintStyle is the style of the hintText
  • filled enables a background colour for the TextField
  • fillColor is the colour of the background colour
  • The border property enables us to customise the border of the TextField by setting it to an OutlineInputBorder widget. By setting the borderRadius property we can have a TextField with rounded corners and by setting the the borderSide property we can remove the border outline.

Back to the TextField properties, using the onChanged callback method of TextField we can execute some code every time the input to the TextField changes.

I set a countryName variable and every time the input changes, I update this variable.

class _SearchScreenState extends State<SearchScreen> {
String countryName;

Inside the onEditingComplete callback method of TextField, we can perform some code when the user taps on the input action button of the keyboard (that is the ‘search’ button in our case).

Here, I use the alpha2_countries package to try to get the ISO 3166–1 alpha-2 country code from the TextField’s input. This is the same code that the API uses.

String countryCode = Countries().resolveCode(countryName);

If a country code was successfully found, I pop the SearchScreen and return the countryCode variable to the StatsScreen.

if (countryCode != null) {
Navigator.pop(context, countryCode);
} else {
showErrorAlert(
message: 'No countries match.',
onRetry: () {
Navigator.of(context).pop();
});
}

If we don’t have a match, I call the showErrorAlert method that I wrote at the top of the _SearchScreenState class.

Future<void> showErrorAlert({String message, Function onRetry}) {
showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return ErrorAlert(
errorMessage: message,
onRetryButtonPressed: onRetry,
);
},
);
}

This is the same kind of popup that we used in Episode 5 for the quiz result.

The ErrorAlert is the error alert method we had in StatsScreen, extracted into an independent widget so we can easily reuse it.

So now if we type ‘United King’ we get:

By setting barrierDismissible to true, we can tap anywhere in the background as well to dismiss the popup.

And that’s it from the SearchScreen.

Back in StatsScreen, in the getData() method, I implemented the LocationSource.Search case of the switch statement.

Remember, I also modified getData() to accept an optional country code.

void getData({String countryCode}) async {

Here’s the search case:

case LocationSource.Search:
if (countryCode != null) {
futureCoronavirusData = CoronavirusService().getLocationDataFromCountryCode(countryCode);
}
break;

First, we need to check if the countryCode that we got from SearchScreen is not null. For example, if the user goes to the SearchScreen and then immediately goes back using the back button of the navigation bar, the countryCode will be null.

Then we call the getLocationDataFromCountryCode() method of CoronavirusService(), passing it the countryCode.

Inside CoronavirusService, I renamed the previous getLocationData() method to getLocationDataFromPlacemark() and I added the new getLocationDataFromCountryCode() method.

Future<CoronavirusData> getLocationDataFromCountryCode(
String countryCode) async {
final response =
await http.get(baseUrl + 'locations?country_code=$countryCode');
if (response.statusCode == 200) {
return CoronavirusData.formatted(
json: jsonDecode(response.body),
country: Countries().resolveName(countryCode),
);
} else {
throw Exception('Failed to load coronavirus data from search.');
}
}

The only difference here is that we pass the countryCode parameter directly to the URL (not through the placemark) and then I’m using the alpha2_countries package resolveName() method to get back the country name from the country code.

And that’s it! We implemented the search functionality.

If we type ‘United Kingdom’ now and tap search, we get:

And that’s it!

Here’s the final code.

There’s obviously many ways to improve this app, by looking for more local data (ex. province/state instead of country), to making the search function less strict (ex. you have to type ‘Iran, Islamic Republic of’ instead of ‘Iran’ to find it), but it will do for this demo.

Thanks for reading, hope you enjoyed this article and stay safe out there… that is, IN YOUR HOUSE.

P.S. On the bright side, the coronavirus-related entertainment on the internet is solid gold.

Like I can’t stop watching this video of this crazy dude on Greek “TV” trying to panic people about the virus and this guy danik making fun of him on the piano. Best thing to come out of the crisis so far. 😂😂😂

ALERT: Next episodes are out!

--

--