Hi everyone! I recently created and published a Flutter app called Flight CO2 Calculator:
And because sharing is caring, I decided to publish the source code, and start a mini series on how to create this app. 😉
This will make for a good example on how to use streams & blocs. And we’ll learn about Flutter search as well!
How this app works
There is a flight details card, where the user can choose the departure and arrival airports, select the flight type (one way or return) and the flight class (economy, business and first).
Once this is done, the app calculates the distance between the two airports, and the corresponding CO2 emissions.
To make this app, we need a few building blocks:
- A dataset of all airports. This is a csv file from OpenFlights.org which contains about 7000 entries.
- A parser, to read all this data into a list of airports.
- An
AirportLookup
service, which takes a search term, and returns a list of airports matching that term. - Some code for calculating the distance and CO2 emissions.
I have structured my project as a Flutter plugin with an example app:
- The plugin code contains all these building blocks.
- The example app includes all the UI.
Part 1: Data and calculations
In this article, I’ll show you the steps needed to create the plugin code. As part of this we will see how to:
- Parse the csv file into a list of strongly-typed
Airport
models. - Write an
AirportLookup
service, which will power a search screen. - Calculate the distance and CO2 emissions.
Ready? Let’s get started.
Parsing the list of Airports
The example app contains a file called airports.dat
. The format of this file is well documented here. We're interested in this subset of fields:
- Name: Name of airport. May or may not contain the City name.
- City: Main city served by airport. May be spelled differently from Name.
- Country: Country or territory where airport is located.
- IATA: 3-letter IATA code. Null if not assigned/unknown.
- ICAO: 4-letter ICAO code.Null if not assigned.
- Latitude: Decimal degrees, usually to six significant digits. Negative is South, positive is North.
- Longitude: Decimal degrees, usually to six significant digits. Negative is West, positive is East.
Here is a sample:
501,"London Biggin Hill Airport","Biggin Hill","United Kingdom","BQH","EGKB",51.33079910279999,0.0324999988079,598,0,"E","Europe/London","airport","OurAirports"
502,"London Gatwick Airport","London","United Kingdom","LGW","EGKK",51.148101806640625,-0.19027799367904663,202,0,"E","Europe/London","airport","OurAirports"
503,"London City Airport","London","United Kingdom","LCY","EGLC",51.505299,0.055278,19,0,"E","Europe/London","airport","OurAirports"
To parse the airports data, we can read the input file and split it line by line:
class AirportDataReader {
static Future<List<Airport>> load(String path) async {
final data = await rootBundle.loadString(path);
return data.split('\n')
.map((line) => Airport.fromLine(line))
.where((airport) => airport != null)
.toList();
}
}
Each line maps to an Airport
instance, which is a model class with the following fields:
class Airport {
Airport({
@required this.name,
@required this.city,
@required this.country,
this.iata,
this.icao,
@required this.location,
});
final String name;
final String city;
final String country;
final String iata;
final String icao;
final LocationCoordinate2D location;
}
class LocationCoordinate2D {
LocationCoordinate2D({this.latitude, this.longitude});
final double latitude;
final double longitude;
}
The parsing of each line is done inside this factory method:
factory Airport.fromLine(String line) {
final components = line.split(",");
if (components.length < 8) {
return null;
}
String name = unescapeString(components[1]);
String city = unescapeString(components[2]);
String country = unescapeString(components[3]);
String iata = unescapeString(components[4]);
if (iata == '\\N') { // placeholder for missing iata code
iata = null;
}
String icao = unescapeString(components[5]);
try {
double latitude = double.parse(unescapeString(components[6]));
double longitude = double.parse(unescapeString(components[7]));
final location = LocationCoordinate2D(
latitude: latitude, longitude: longitude);
return Airport(
name: name,
city: city,
country: country,
iata: iata,
icao: icao,
location: location,
);
} catch (e) {
// see source code for how errors are handled
print(e);
return null;
}
}
// All fields are escaped with double quotes. This method deals with them
static String unescapeString(dynamic value) {
if (value is String) {
return value.replaceAll('"', '');
}
return null;
}
This code is similar to what we would normally write to parse JSON, except that the input is a line of comma-separated values.
To parse this, we split the line into multiple components, and we use their position to identify them (as opposed to reading keys in a JSON map).
Airport Lookup
This is a service that matches a search keyword against the entire data-set of airports.
It can be implemented as follows:
class AirportLookup {
AirportLookup({this.airports});
final List<Airport> airports;
List<Airport> searchString(String string) {
string = string.toLowerCase();
final matching = airports.where((airport) {
final iata = airport.iata ?? '';
return iata.toLowerCase() == string ||
airport.name.toLowerCase() == string ||
airport.city.toLowerCase() == string ||
airport.country.toLowerCase() == string;
}).toList();
// found exact matches
if (matching.length > 0) {
return matching;
}
// search again with less strict criteria
return airports.where((airport) {
final iata = airport.iata ?? '';
return iata.toLowerCase().contains(string) ||
airport.name.toLowerCase().contains(string) ||
airport.city.toLowerCase().contains(string) ||
airport.country.toLowerCase().contains(string);
}).toList();
}
}
This code works by filtering the full airport list with a couple of different criteria. Filtering is done with the where
functional method in Dart.
A first filtering pass is done to find an exact match between the search string, and any of the "iata", "name", "city" or "country" properties.
If no match is found, a less strict comparison is made to see if any of the above properties contains the search string.
Distance and CO2 calculation
Given two airports, we can calculate their distance as a function of their location coordinates. In other words:
Distance = f(locationA, locationB)
This is implemented by a DistanceCalculator
class, which is an adapted version of the code in this StackOverflow answer:
class DistanceCalculator {
// distance in meters to another location
// http://stackoverflow.com/questions/12966638/how-to-calculate-the-distance-between-two-gps-coordinates-without-using-google-m
static double distanceInMetersBetween(LocationCoordinate2D lhs, LocationCoordinate2D rhs) {
double rad_per_deg = pi / 180.0; // PI / 180
double rkm = 6371.0;// Earth radius in kilometers
double rm = rkm * 1000.0;// Radius in meters
double dlat_rad = (rhs.latitude - lhs.latitude) * rad_per_deg; // Delta, converted to rad
double dlon_rad = (rhs.longitude - lhs.longitude) * rad_per_deg;
double lat1_rad = lhs.latitude * rad_per_deg;
double lat2_rad = rhs.latitude * rad_per_deg;
double sinDlat = sin(dlat_rad/2);
double sinDlon = sin(dlon_rad/2);
double a = sinDlat * sinDlat + cos(lat1_rad) * cos(lat2_rad) * sinDlon * sinDlon;
double c = 2.0 * atan2(sqrt(a), sqrt(1-a));
return rm * c;
}
static double distanceInKmBetween(LocationCoordinate2D lhs, LocationCoordinate2D rhs) {
return distanceInMetersBetween(lhs, rhs) / 1000.0;
}
}
Given the distance, it is possible to calculate the CO2 emissions with a formula based on this paper.
CO2 = f(distance, flightClass)
The maths for this involves a lot of different parameters. I'm not including it here for simplicity, but you can take a peek at the source code if you're interested.
Wrap up
That's it for today. In the next article I'll show how to build the Flutter UI to create the final app.
And if you can't wait, the full source code is already available here on GitHub. 🙏
Happy coding!