Making Your API Calls In Flutter The Right Way
Smartphones are used everywhere. Daily usage of smartphones includes entertainment, banking, shopping, videos, images, etc. For smartphones to do many of the things their users request, applications on the smartphone need internet access. Internet access is needed to book movie tickets, order for food, download and watch videos, etc.
To develop apps that fetch data from the Internet, you’ll need to know how to make network requests and how to handle the responses properly.
One could have wondered how we get data from anywhere in just a click of a button. Say we want to access weather data, we can access the data of any country from anywhere in just a matter of seconds. How is that possible? It’s all thanks to the heroes we call ‘APIs’.
What is an API?
An application programming interface (API) is a computing interface which defines interactions between multiple software intermediaries. It defines the kinds of calls or requests that can be made, how to make them, the data formats that should be used, the conventions to follow, etc. It can also provide extension mechanisms so that users can extend existing functionality in various ways and to varying degrees — Wikipedia
Let’s consider this scenario, in which you(client) are in a restaurant. You sit on your table and decide to eat A. You need to call the waiter(API call) and place your order(request) for A. The waiter gets to the kitchen(server) to prepare A and serves(response) A. Now you get your delicious food A(JSON or XML data) and everyone is happy 😂. In this case, your interface between you and the kitchen(Server) is your waiter(API). It’s his responsibility to carry the request from you to the kitchen(Server), make sure it’s getting done, and you know once it is ready he gets back to you as a response(JSON or XML data).
Going back to our context, you are the client in the need of data and you hit the server with an API call and request for the data. The server will process this request and send back the response to you in JSON or XML format.
We would cover a few things in this article to show the right way in making API network calls.
Often, people go with the idea of writing a function to get the data and set the state of a particular variable to the API results in the Views/UI. While this may work, it is not ideal.
We would be creating a simple movie listing app using TMDB API. The application displays popular movies and also shows the movie details. Check here for the application code.
The following items would be covered:
- BLOC Architecture
- Network Setup/API calls
- Repositories and BLoCS
- UI
BLOC Architecture
BLOC gives you a data stream that can be updated by adding new data through streams instead of ViewModel. To learn more about stream, check this article by flutter community.
Time to fire
Open your favorite IDE and create a flutter project. You can also create the project from the terminal by running:
flutter create flurest
After creating the project we’ll need to set up our packages:
Name them “blocs”, “models”, “networking”, “repository” and “view” as shown below. These directory names explain what each one does but if it’s not clear, fear not for it will become more clearer as we get deeper into it. I suggest using this architecture for all your new Flutter projects as you’ll be able to separate and manage all your business logic properly and easily.
We should also add our dependencies. Since we would be making network calls, our project needs the HTTP library. Open your pubspec.yaml and add the plugin:
name: flurest
description: Making your API calls the right way# The following line prevents the package from being accidentally published to
# pub.dev using `pub publish`. This is preferred for private packages.publish_to: 'none' # Remove this line if you wish to publish to pub.devversion: 1.0.0+1environment: sdk: ">=2.7.0 <3.0.0"dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^0.1.3
http: ^0.12.1
dev_dependencies:
flutter_test:
sdk: flutter
Run flutter packages get or do a “packages get” in Android Studio.
Network Setup
We would be connecting to TMDB API whose URL is in the form http://api.themoviedb.org/3/movie/popular?api_key=“Your_Api_Key”. You can head over to TMDB for an API key. The API churns out popular movies.
MovieResponse modal Class
We should generate our models from the JSON response.
class MovieResponse {
int totalResults;
List<Movie> results;MovieResponse({this.page, this.totalResults, this.totalPages, this.results});MovieResponse.fromJson(Map<String, dynamic> json) {
page = json['page'];
totalResults = json['total_results'];
if (json['results'] != null) {
results = new List<Movie>();
json['results'].forEach((v) {
results.add(new Movie.fromJson(v));
});
}
}class Movie {
int id;
var voteAverage;
String title;
String posterPath;
String overview;
String releaseDate;Movie(
{this.id,
this.voteAverage,
this.title,
this.posterPath,,
this.overview,
this.releaseDate});Movie.fromJson(Map<String, dynamic> json) {
id = json['id'];
voteAverage = json['vote_average'];
title = json['title'];
posterPath = json['poster_path'];
overview = json['overview'];
releaseDate = json['release_date'];
}
}
The above is a basic modal class to hold our movie data.
API Base Helper Class
For making communication between our Application and API we’ll need three API classes that need some type of HTTP methods to get executed. Let’s dive into the networking directory and create a base API helper class, which will be going to help us communicate with our server.
This helper class contains an HTTP Get method which then can be used by our repository class. In case of applications where you make other forms of request/methods such as “POST”, “DELETE”, “PUT”, you should add them in this helper class.
import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:flurest/networking/api_exceptions.dart';
import 'dart:async';class ApiBaseHelper {
final String _baseUrl = "http://api.themoviedb.org/3/";Future<dynamic> get(String url) async {
print('Api Get, url $url');
var responseJson;
try {
final response = await http.get(_baseUrl + url);
responseJson = _returnResponse(response);
} on SocketException {
print('No net');
throw FetchDataException('No Internet connection');
}
print('api get recieved!');
return responseJson;
}dynamic _returnResponse(http.Response response) {
switch (response.statusCode) {
case 200:
var responseJson = json.decode(response.body.toString());
print(responseJson);
return responseJson;
case 400:
throw BadRequestException(response.body.toString());
case 401:
case 403:
throw UnauthorisedException(response.body.toString());
case 500:
default:
throw FetchDataException(
'Error occured while Communication with Server with StatusCode : ${response.statusCode}');
}
}
In the above helper class, certain exceptions are handled. We are yet to mention exceptions here so why handle them? Every HTTP request on execution returns some type of status codes based on its status. What happens if the request fails? Does our app misbehave? Does our app crash? Not handling such exceptions in-app can lead to poor app rating and usage as your app would behave funny when the request fails.
We should not worry about the exceptions in the API base helper class as they are custom app exceptions which we are going to create in our next step.
App Exceptions
As explained earlier, what happens to our app if the HTTP request fails? Does the app misbehave? acts funny? We don’t want any of these to happen if the request fails hence we are going to handle most of them in our app. For doing so are going to create our custom app exceptions which we can throw based on the response status code.
class AppException implements Exception {
final _message;
final _prefix;AppException([this._message, this._prefix]);String toString() {
return "$_prefix$_message";
}
}class FetchDataException extends AppException {
FetchDataException([String message])
: super(message, "Error During Communication: ");
}class BadRequestException extends AppException {
BadRequestException([message]) : super(message, "Invalid Request: ");
}class UnauthorisedException extends AppException {
UnauthorisedException([message]) : super(message, "Unauthorised: ");
}class InvalidInputException extends AppException {
InvalidInputException([String message]) : super(message, "Invalid Input: ");
}
That is not all to handling the API exceptions. We also have to handle all our API responses on the UI thread. We would do that with an API response class, not a model this time around.
class ApiResponse<T> {
Status status;T data;String message;ApiResponse.loading(this.message) : status = Status.LOADING;ApiResponse.completed(this.data) : status = Status.COMPLETED;ApiResponse.error(this.message) : status = Status.ERROR;@override
String toString() {
return "Status : $status \n Message : $message \n Data : $data";
}
}enum Status { LOADING, COMPLETED, ERROR }
What we are doing in the above? We expose all those HTTP errors and exceptions to our UI through a generic class that encloses both the network status and the data coming from the API.
……..there we have it. All networking layers complete. At this point, you may want to go over what we have done.
Up next, are our repository and bloc classes. One can refer to the repository classes as some form of ‘middle-men’ or “gateways” to our data source. Some kind of mediator and abstraction between our UI and API.
We’ll only need 2 repository classes and 2 blocs as we would be hitting 2 endpoints(one for a list of popular movies and the other for the movie detail).
Repository Class
As mentioned earlier, we’ll only need 2 repository classes and 2 blocs as we would be hitting 2 endpoints(one for a list of popular movies and the other for the movie detail). The job of the repository is to deliver movie data to the BLoC after fetching it from the API.
Our first repository would be the movie repository
import 'package:flurest/networking/api_base_helper.dart';
import 'package:flurest/models/movie_response.dart';
import 'package:flurest/apiKey.dart';class MovieRepository {
final String _apiKey = apiKey;ApiBaseHelper _helper = ApiBaseHelper();Future<List<Movie>> fetchMovieList() async {
final response = await _helper.get("movie/popular?api_key=$_apiKey");
return MovieResponse.fromJson(response).results;
}
}
From the above, we fetch the movie list and pass the response in the format described in the MovieResponse class under our models(movie_response.dart).
Next, we create our movie details repository
import 'package:flurest/networking/api_base_helper.dart';
import 'package:flurest/models/movie_response.dart';
import 'package:flurest/apiKey.dart';class MovieDetailRepository {
final String _apiKey = apiKey;ApiBaseHelper _helper = ApiBaseHelper();Future<Movie> fetchMovieDetail(int selectedMovie) async {
final response = await _helper.get("movie/$selectedMovie?api_key=$_apiKey");
return Movie.fromJson(response);
}
}
From the above, we fetch the details of a particular movie and pass the response in the format described in the Movie class under our models(movie_response.dart).
BLoC
BLoC which stands for Business Logic Components represents a stream of events talking to the UI(widgets). The UI widgets get notified by the bloc loading the data if the loading is complete, still being loaded or an error occurred while loading.
We are going to create 2 blocs for acting according to the various UI events. We would create a movie bloc and a movie detail bloc. Their task is to handle the “fetch movie list” and “fetch movie detail” event, adding the returned data to the Sink which then can be easily listened by our UI with the help of StreamBuilder.
We should not forget our API response has 3 states: ‘loading’ which notifies the UI when data is loading, ‘completed’ which notifies the UI when the data has successfully loaded, ‘error’ which notifies the UI that an error occurred while fetching the data.
movie_bloc.dart
import 'dart:async';import 'package:flurest/networking/api_response.dart';
import 'package:flurest/repository/movie_repository.dart';import 'package:flurest/models/movie_response.dart';class MovieBloc {
MovieRepository _movieRepository;StreamController _movieListController;StreamSink<ApiResponse<List<Movie>>> get movieListSink =>
_movieListController.sink;Stream<ApiResponse<List<Movie>>> get movieListStream =>
_movieListController.stream;MovieBloc() {
_movieListController = StreamController<ApiResponse<List<Movie>>>();
_movieRepository = MovieRepository();
fetchMovieList();
}fetchMovieList() async {
movieListSink.add(ApiResponse.loading('Fetching Movies'));
try {
List<Movie> movies = await _movieRepository.fetchMovieList();
movieListSink.add(ApiResponse.completed(movies));
} catch (e) {
movieListSink.add(ApiResponse.error(e.toString()));
print(e);
}
}dispose() {
_movieListController?.close();
}
}
movie_detail_bloc.dart
import 'dart:async';
import 'package:flurest/models/movie_response.dart';
import 'package:flurest/networking/api_response.dart';
import 'package:flurest/repository/movie_detail_repository.dart';class MovieDetailBloc {
MovieDetailRepository _movieDetailRepository;StreamController _movieDetailController;StreamSink<ApiResponse<Movie>> get movieDetailSink =>
_movieDetailController.sink;Stream<ApiResponse<Movie>> get movieDetailStream =>
_movieDetailController.stream;MovieDetailBloc(selectedMovie) {
_movieDetailController = StreamController<ApiResponse<Movie>>();
_movieDetailRepository = MovieDetailRepository();
fetchMovieDetail(selectedMovie);
}fetchMovieDetail(int selectedMovie) async {
movieDetailSink.add(ApiResponse.loading('Fetching Details'));
try {
Movie details =
await _movieDetailRepository.fetchMovieDetail(selectedMovie);
movieDetailSink.add(ApiResponse.completed(details));
} catch (e) {
movieDetailSink.add(ApiResponse.error(e.toString()));
print(e);
}
}dispose() {
_movieDetailController?.close();
}
}
Cool……… We are almost there. Its time to work on our UI. 😎😎😎
Views
First, our main.dart file. Here we have a material app with our home set to be the movie screen which shows popular movies.
import 'package:flurest/view/movie_list.dart';
import 'package:flutter/material.dart';void main() => runApp(MyApp());class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flurest',
home: MovieScreen(),
);
}
}
It’s preferable to keep all views together hence we would be adding our views in the views directory. We are having 2 views — One for the movie list and the other for movie detail.
movie_list.dart
import 'package:flurest/blocs/movie_bloc.dart';
import 'package:flurest/models/movie_response.dart';
import 'package:flurest/networking/api_response.dart';
import 'package:flurest/view/movie_detail.dart';
import 'package:flutter/material.dart';class MovieScreen extends StatefulWidget {
@override
_MovieScreenState createState() => _MovieScreenState();
}class _MovieScreenState extends State<MovieScreen> {
MovieBloc _bloc;@override
void initState() {
super.initState();
_bloc = MovieBloc();
}@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0.0,
title: Text(
'Moviez',
style: TextStyle(
fontSize: 28,
),
),
),
body: RefreshIndicator(
onRefresh: () => _bloc.fetchMovieList(),
child: StreamBuilder<ApiResponse<List<Movie>>>(
stream: _bloc.movieListStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
switch (snapshot.data.status) {
case Status.LOADING:
return Loading(loadingMessage: snapshot.data.message);
break;
case Status.COMPLETED:
return MovieList(movieList: snapshot.data.data);
break;
case Status.ERROR:
return Error(
errorMessage: snapshot.data.message,
onRetryPressed: () => _bloc.fetchMovieList(),
);
break;
}
}
return Container();
},
),
),
);
}@override
void dispose() {
_bloc.dispose();
super.dispose();
}
}class MovieList extends StatelessWidget {
final List<Movie> movieList;const MovieList({Key key, this.movieList}) : super(key: key);@override
Widget build(BuildContext context) {
return GridView.builder(
itemCount: movieList.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 1.5 / 1.8,
),
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => MovieDetail(movieList[index].id)));
},
child: Card(
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Image.network(
'https://image.tmdb.org/t/p/w342${movieList[index].posterPath}',
fit: BoxFit.fill,
),
),
),
),
);
},
);
}
}class Error extends StatelessWidget {
final String errorMessage;final Function onRetryPressed;const Error({Key key, this.errorMessage, this.onRetryPressed})
: super(key: key);@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.red,
fontSize: 18,
),
),
SizedBox(height: 8),
RaisedButton(
color: Colors.redAccent,
child: Text(
'Retry',
),
onPressed: onRetryPressed,
)
],
),
);
}
}class Loading extends StatelessWidget {
final String loadingMessage;const Loading({Key key, this.loadingMessage}) : super(key: key);@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
loadingMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
),
),
SizedBox(height: 24),
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.lightGreen),
),
],
),
);
}
}
In the above movie list screen, we check the snapshot data status and switch case from LOADING, COMPLETED, ERROR. The data is also updated through Streams and not ViewModel.
movie_detail.dart
import 'package:flurest/blocs/movie_detail_bloc.dart';
import 'package:flurest/models/movie_response.dart';
import 'package:flurest/networking/api_response.dart';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;class MovieDetail extends StatefulWidget {
final int selectedMovie;
const MovieDetail(this.selectedMovie);@override
_MovieDetailState createState() => _MovieDetailState();
}class _MovieDetailState extends State<MovieDetail> {
MovieDetailBloc _movieDetailBloc;@override
void initState() {
super.initState();
_movieDetailBloc = MovieDetailBloc(widget.selectedMovie);
}@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0.0,
title: Text(
'Moviez',
style: TextStyle(
fontSize: 20,
),
),
),
body: RefreshIndicator(
onRefresh: () =>
_movieDetailBloc.fetchMovieDetail(widget.selectedMovie),
child: StreamBuilder<ApiResponse<Movie>>(
stream: _movieDetailBloc.movieDetailStream,
builder: (context, snapshot) {
if (snapshot.hasData) {
switch (snapshot.data.status) {
case Status.LOADING:
return Loading(loadingMessage: snapshot.data.message);
break;
case Status.COMPLETED:
return ShowMovieDetail(displayMovie: snapshot.data.data);
break;
case Status.ERROR:
return Error(
errorMessage: snapshot.data.message,
onRetryPressed: () =>
_movieDetailBloc.fetchMovieDetail(widget.selectedMovie),
);
break;
}
}
return Container();
},
),
),
);
}@override
void dispose() {
_movieDetailBloc.dispose();
super.dispose();
}
}class ShowMovieDetail extends StatelessWidget {
final Movie displayMovie;ShowMovieDetail({Key key, this.displayMovie}) : super(key: key);@override
Widget build(BuildContext context) {
return new Scaffold(
body: Stack(fit: StackFit.expand, children: [
new Image.network(
'https://image.tmdb.org/t/p/w342${displayMovie.posterPath}',
fit: BoxFit.cover,
),
new BackdropFilter(
filter: new ui.ImageFilter.blur(sigmaX: 5.0, sigmaY: 5.0),
child: new Container(
color: Colors.black.withOpacity(0.5),
),
),
new SingleChildScrollView(
child: new Container(
margin: const EdgeInsets.all(20.0),
child: new Column(
children: <Widget>[
new Container(
alignment: Alignment.center,
child: new Container(
width: 400.0,
height: 400.0,
),
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(10.0),
image: new DecorationImage(
image: new NetworkImage(
'https://image.tmdb.org/t/p/w342${displayMovie.posterPath}'),
fit: BoxFit.cover),
boxShadow: [
new BoxShadow(
blurRadius: 20.0,
offset: new Offset(0.0, 10.0))
],
),
),
new Container(
margin: const EdgeInsets.symmetric(
vertical: 20.0, horizontal: 0.0),
child: new Row(
children: <Widget>[
new Expanded(
child: new Text(
displayMovie.title,
style: new TextStyle(
color: Colors.white,
fontSize: 30.0,
fontFamily: 'Arvo'),
)),
new Text(
displayMovie.voteAverage.toStringAsFixed(2),
// '${widget.movie['vote_average']}/10',
style: new TextStyle(
color: Colors.white,
fontSize: 20.0,
fontFamily: 'Arvo'),
)
],
),
),
new Text(displayMovie.overview,
style:
new TextStyle(color: Colors.white, fontFamily: 'Arvo')),
new Padding(padding: const EdgeInsets.all(10.0)),
new Row(
children: <Widget>[
new Expanded(
child: new Container(
width: 150.0,
height: 60.0,
alignment: Alignment.center,
child: new Text(
'Rate Movie',
style: new TextStyle(
color: Colors.white,
fontFamily: 'Arvo',
fontSize: 20.0),
),
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(10.0),
color: const Color(0xaa3C3261)),
)),
new Padding(
padding: const EdgeInsets.all(16.0),
child: new Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: new Icon(
Icons.share,
color: Colors.white,
),
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(10.0),
color: const Color(0xaa3C3261)),
),
),
new Padding(
padding: const EdgeInsets.all(8.0),
child: new Container(
padding: const EdgeInsets.all(16.0),
alignment: Alignment.center,
child: new Icon(
Icons.bookmark,
color: Colors.white,
),
decoration: new BoxDecoration(
borderRadius: new BorderRadius.circular(10.0),
color: const Color(0xaa3C3261)),
)),
],
)
],
),
),
)
]),
);
}
}class Error extends StatelessWidget {
final String errorMessage;final Function onRetryPressed;const Error({Key key, this.errorMessage, this.onRetryPressed})
: super(key: key);@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
errorMessage,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.red,
fontSize: 18,
),
),
SizedBox(height: 8),
RaisedButton(
color: Colors.redAccent,
child: Text(
'Retry',
style: TextStyle(
),
),
onPressed: onRetryPressed,
)
],
),
);
}
}class Loading extends StatelessWidget {
final String loadingMessage;const Loading({Key key, this.loadingMessage}) : super(key: key);@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
loadingMessage,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
),
),
SizedBox(height: 24),
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.lightGreen),
),
],
),
);
}
}
Similarly, we check the snapshot data status and switch case from LOADING, COMPLETED, ERROR. The data is also updated through Streams and not ViewModel.
For your API key, you should create the file apiKey.dart in your project tooth containing the following:
String apiKey = "Your_API_Key";
And there we have it our app runs 💃💃💃 and should display as shown below:
Bonus
While pushing your app to Github, you should not push your API Key. Say, for instance, you are running some GitHub actions to build the application on push or pull request, the application build will surely fail as it can’t find the API key.
So what do you do? GitHub has a service called Secrets. Every repository has its secrets. You should add your API key. That begs the question “how will the application/GitHub actions know where to find the key”.
You should edit your apiKey.dart file to match the below, assuming you saved the secrets as api_key
apiKey.dart
import 'dart:io' show Platform;String apiKey = Platform.environment['api_key'];// we comment the previous line our
// String apiKey = "Your_API_KEY";
This way the API key is called an environment variable as all Secrets of a repository are environment variables though they are not available at runtime.
By the way, top Flutter engineers around the globe love our tweets and we think you will love them too because we make interesting tweets about Flutter! Want to join our Twitter community? Click the link below
Other Useful Resources
Flutter Stream Basics
Handling Network Calls Like A Boss
Handling Network Calls Like A Pro In Flutter (Original Concept)
The complete project can be found below.