In this series of articles, you'll learn how to create a Flutter mobile application that connects to a secured web API. You'll start by building a basic Flutter app that connects to an open API. Once you've written the basic app, you'll learn how to set up the app to use Auth0 to log in. From there, you'll update the app so that it can connect to a secured API. Along the way, you'll learn how to structure your application using a well-known design pattern.
In this article and the one after it, you'll build the basic Flutter app that lets you read, add, edit, and delete items from your wishlist. This basic app won't require the user to log in. You'll add user login and connecting to a secure API in subsequent articles.
Flutter is a UI toolkit from Google that has become a popular choice for building cross-platform applications. As it has its own rendering engine, it enables developers to build applications with a more consistent look and feel on multiple platforms more easily, which is helpful when building applications with branded experiences.
By integrating your apps with Auth0, you can control access to resources and information without having to worry about the details of authentication.
Getting Started
The application featured in this article was written using version 1.22.5 of the Flutter SDK. This was the latest stable version when this tutorial was written. If you need to set up an environment for Flutter development, follow the guide on the official Flutter website.
While the current version of the Flutter SDK at the time of writing is 2.0.3, this article's code is compatible with this SDK.
Applications written under older versions of the Flutter SDK will generally work on newer versions unless breaking changes were introduced. If you run into this situation, consult the release notes; they provide more information on breaking changes and how to migrate your code. Alternatively, you can use an older SDK release, which can be found here.
The application that you'll build will interact with a web API that manages wishlists. In the first part of this article, the app will connect to an API that hasn't been secured. This will make it easier to confirm that the application is able to send and receive information via the API. Later on, the application will connect to a secured API, where you'll use authentication with Auth0.
A template for the API has already been created — all you have to do is generate your own instance. Here's how you do it:
- Open the Glitch project at https://glitch.com/edit/#!/wishlist-public-api. This is the main version of the API project.
- Click on the Remix to Edit button in the top-right corner. This will create your own copy of the API project, which you can edit and run. Your project will be assigned a name made of three random words separated by hyphens (e.g., aardvark-calculator-mousse), which will appear in the top left corner of the page.
- Click on the Share button, which you can find under the project’s name. A pop-up titled Share your project will appear. In the Project links section at the bottom of the pop-up, make a note of the Live site link. This is the root URL of your server, which your application will use to access the API.
The URL of your server will have the form
https://*project-name-here*.glitch.me
, where project-name-here is the name of your Glitch project.
What you'll build
Before we begin, let's take a sneak peek at the Flutter app you'll build:
Here's the screen the user will see when the app launches:
Here's the screen that displays the wishlist to the user. In this screenshot, it's loading the wishlist items:
And finally, here's the “Add Item” screen, where the user enters the details for an item to be added to the wishlist:
Users will sign into the application via an Auth0 login page. Once signed in, they'll be able to manage items within their wishlist.
Flutter apps are written in the Dart programming language. If you have experience with languages like JavaScript, TypeScript, or C#, you'll find that Dart is pretty similar to them.
Create the Project
The first step is to create a new project.
Even though there are IDEs that support Flutter development, we’ll use the command line and the commands provided with the SDK to create a new project. If you followed the official documentation to get your environment up and running, the SDK would have been added to your path. This means that you can create a new project by entering this command:
flutter create --org com.auth0 flutter_wishlist_app
This command creates a new Flutter project named flutter_wishlist_app
. The --org
switch specifies that the string “com.auth0” will be used in the application ID for the Android version of the app and as the prefix of the bundle identifier for the iOS version.
After the command is done, you'll have a directory called flutter_wishlist_app
, where the project's files will be located. Navigate to this directory:
cd flutter_wishlist_app
The project directory contains all the necessary files for a simple starter app that you can run right away. Connect a device to your computer or launch an Android emulator or iOS Simulator, then run the application via the CLI with the following command:
flutter run
You should get a basic application that displays a counter that will increase each time you tap on the plus button:
Note: there's a difference in the name of the repository, and the name of the application as the Flutter/Dart applications cannot have hyphens in the name.
Clean Up the Starter App
Using your preferred IDE or the command line, open the project directory. It should have the following layout:
Within the test
directory is a set of tests that apply to the starter application you just ran. Go ahead and remove all of the files within test
. You won't be writing tests as part of this tutorial, but it is recommended that you do so for production applications.
Install Dependencies
Our wishlist app makes use of a handful of libraries, packages, and plugins that we'll include in the project as dependencies. They are:
- http: a library for creating network requests
- provider: a library that can be used for state management
- build_runner: a package that is commonly used for code generation
- json_serializable: generates code for dealing with JSON
- json_annotation: a companion for the json_serializable package that provides annotations to mark classes that would be involved in handling JSON
- url_launcher: a plugin for opening URLs
You specify the dependencies used by a Flutter project in its pubspec file. Its name is pubspec.yaml
, and it's located in the project's root directory.
Open pubspec.yaml
and replace its dependencies
section with the following:
dependencies:
flutter:
sdk: flutter
http: ^0.12.2
json_annotation: ^3.1.1
provider: ^4.3.2+3
url_launcher: ^5.7.10
The dependencies
section of pubspec.yaml
tells Flutter which dependencies to include when building the app. The numbers after the http
, json_annotation
, provider
, and url_launcher
dependencies specify which versions should be used.
Note that you didn't add any lines for the build_runner
and json_serializable
dependencies. This is because they’re not part of the application. Instead, they’re tools to automate the process of building the application. As such, they’re classified as dev dependencies. You specify their use in the dev_dependencies
section of pubspec.yaml
.
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^1.10.7
json_serializable: ^3.5.1
Save the changes that you made to pubspec.yaml
.
Now that you've entered the project's dependencies, it's time to retrieve them from Pub.dev, the package repository for Dart and Flutter applications. The way you'll do this depends on the tools you’re using.
If you’re building the app using an IDE or Visual Studio Code, it may detect that you’ve updated the project's dependencies file and provide you with the options to get the dependencies from Pub.dev. When presented with this option, say yes.
Alternatively, if you’re building the app using a code editor and the command line, get the dependencies by running the following command from within the project's root directory:
flutter pub get
You now have a basic Flutter app project, complete with the dependencies required for the final project. The next step is to set up the classes for storing and sending data.
The Data Service and Data Model Classes
Create the Data Transfer Objects
Our wishlist application will make use of the the following endpoints provided by the web API:
- A GET endpoint for retrieving the user's wishlist
- A POST endpoint for adding an item to the wishlist
- A PUT endpoint for editing an item in the wishlist
- A DELETE endpoint for deleting an item from the wishlist
The GET endpoint doesn’t need to be provided with any information. The other endpoints, POST, PUT, and DELETE, require the details of the item to be added, edited, or deleted. This requires us to create data model classes to represent wishlist items. We'll put these classes into their own directory, lib/models/api
.
To create this directory, add a new directory named models/api
to the project's lib
directory.
The first class you'll add to the newly-created lib/models/api
directory will be AddItemRequestDTO
, which will represent a request to add an item to the wishlist. The DTO
suffix denotes that its instances are data transfer objects.
Create a file in lib/models/api
named add_item_request_dto.dart
and add the following code to it:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'add_item_request_dto.g.dart';
()
class AddItemRequestDTO {
final String name;
final String description;
final String url;
const AddItemRequestDTO({
this.name,
this.description,
this.url,
});
factory AddItemRequestDTO.fromJson(Map<String, dynamic> json) =>
_$AddItemRequestDTOFromJson(json);
Map<String, dynamic> toJson() => _$AddItemRequestDTOToJson(this);
}
The AddItemRequestDTO
class has the following:
- The properties
name
,description
, andurl
, which will hold the name, description, and URL of the item to be added to the wishlist - A constructor that sets those properties
- The methods
fromJson()
andtoJson()
, which convert the class properties to and from JSON
You may have noticed that fromJson()
references a function named _$AddItemRequestDTOFromJson()
and toJson()
references a function named _$AddItemRequestDTOToJson()
. Neither of these functions exists...yet. The @JsonSerializable()
annotation, which appears on the line before the start of the class, indicates that the Dart build system should generate those JSON conversion functions and that they should reside in the file specified in the part
statement: add_item_request_dto.g.dart
, where the g
in the filename means "generated".
Since the _$AddItemRequestDTOFromJson()
and _$AddItemRequestDTOToJson()
functions and the add_item_request_dto.g.dart
file haven’t yet been made, your editor or IDE might point out them out as errors. You can ignore them for now.
The next class to add is EditItemRequestDTO
, which holds the details for a request to edit an item. Create a file called edit_item_request_dto.dart
in the lib/models/api
directory with the following code:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'edit_item_request_dto.g.dart';
()
class EditItemRequestDTO {
final String name;
final String description;
final String url;
const EditItemRequestDTO({
this.name,
this.description,
this.url,
});
factory EditItemRequestDTO.fromJson(Map<String, dynamic> json) =>
_$EditItemRequestDTOFromJson(json);
Map<String, dynamic> toJson() => _$EditItemRequestDTOToJson(this);
}
Notice that EditItemRequestDTO
has the same properties and methods as AddItemRequestDTO
. This is to be expected, as they both deal with wishlist items. Even though they have the same data structure, it's best to keep separate classes for “add” and “edit” requests as they are used to reach different endpoints. This reduces the impact on the code and testing in case one endpoint changes, but the other doesn't.
Just like AddItemRequestDTO
, EditItemRequestDTO
uses the @JsonSerializable()
annotation to specify that the functions to convert its fields to and from JSON should be auto-generated and stored in a file named edit_item_request_dto.g.dart
.
The last class to add, ItemDTO
, represents an item in a user's wishlist. Create a file named item_dto.dart
in the lib/models/api
directory with the following code:
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'item_dto.g.dart';
()
class ItemDTO {
final String id;
final String name;
final String description;
final String url;
const ItemDTO({
this.id,
this.name,
this.description,
this.url,
});
factory ItemDTO.fromJson(Map<String, dynamic> json) =>
_$ItemDTOFromJson(json);
Map<String, dynamic> toJson() => _$ItemDTOToJson(this);
}
ItemDTO
is similar to AddItemRequestDTO
and EditItemRequestDTO
. The only notable difference is that ItemDTO
contains an additional property: id
, which holds the ID of the wishlist item.
With that done, you have finished modelling all of the data that will be sent to and from the APIs. Enter the following on the command line to generate the code that allows the AddItemRequestDTO
, EditItemRequestDTO
, and ItemDTO
classes to convert their data to and from JSON:
flutter pub run build_runner build
Once the command has finished running, the build system will have created all the files with the g.dart
extension that were referenced in our code, along with the functions they contain. This should resolve the errors that your editor or IDE may have highlighted in AddItemRequestDTO
, EditItemRequestDTO
, and ItemDTO
.
If you are interested in reading more on JSON serialization for Flutter applications, a good place to start is the JSON and serialization page on the Flutter site.
Create the Domain Models
Although you have created models for the data that is sent to and from the APIs, they may contain more information than needed. This could be solved through the use of domain models that better represent and limit the data that the application actually deals with. Doing so also provides an anti-corruption layer, as referred to in domain-driven design. This helps isolate changes that could occur if the schema of the request or response body of an API changes.
Conceptually, users are only concerned with two things:
- The wishlist
- Items within the wishlist
You’ll need to create classes for both of these.
Items within the wishlist will be represented by instances of the Item
class. Create a file named item.dart
in the lib/models
directory that contains the following code:
import 'package:flutter/foundation.dart';
class Item {
final String name;
final String description;
final String url;
final String id;
const Item({
this.name,
this.description,
this.url,
this.id,
});
}
The properties should be self-explanatory. Note that id
is optional; that's because its value will be set only for existing items. When adding new items, the value for id
will not be specified.
The wishlist, which will contain a collection of items, will be represented by Wishlist
class. Create a file named wishlist.dart
in the lib/models
directory, with the code shown below:
import 'item.dart';
class Wishlist {
final List<Item> items;
Wishlist(this.items);
}
Create a Service to Manage the Wishlist
With the DTOs and domain models in place, you are now ready to create the code that will communicate with the APIs. This will be implemented in the WishlistService
class, which is responsible for managing the user's wishlist. This class puts the code for making the web requests in a single, centralized place, which encourages reuse and facilitates the writing of tests as well.
Create a subdirectory of lib
named services
. The project will now have a lib/services
directory. Create a file named wishlist_service.dart
in lib/services
and put the following code inside it:
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/api/add_item_request_dto.dart';
import '../models/api/edit_item_request_dto.dart';
import '../models/api/item_dto.dart';
import '../models/item.dart';
import '../models/wishlist.dart';
class WishlistService {
static const String itemsApiUrl = 'YOUR_ITEMS_API_URL';
WishlistService();
Future<Wishlist> getWishList() async {
// TODO: send additional info to be able to access protected endpoint
final http.Response response = await http.get(itemsApiUrl);
if (response.statusCode == 200) {
final List<Object> decodedJsonList = jsonDecode(response.body);
final List<ItemDTO> items = List<ItemDTO>.from(
decodedJsonList.map((json) => ItemDTO.fromJson(json)));
return Wishlist(items
?.map((ItemDTO itemDTO) => Item(
id: itemDTO.id,
name: itemDTO.name,
description: itemDTO.description,
url: itemDTO.url))
?.toList());
}
throw Exception('Could not get the wishlist');
}
Future<String> addItem(Item item) async {
final AddItemRequestDTO addItemRequest = AddItemRequestDTO(
name: item.name, description: item.description, url: item.url);
// TODO: send additional info to be able to access protected endpoint
final http.Response response = await http.post(itemsApiUrl,
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(addItemRequest.toJson()));
if (response.statusCode == 201) {
return response.body;
}
throw Exception('Could not add item');
}
Future<String> editItem(Item item) async {
final EditItemRequestDTO editItemRequest = EditItemRequestDTO(
name: item.name, description: item.description, url: item.url);
// TODO: send additional info to be able to access protected endpoint
final http.Response response = await http.put('$itemsApiUrl/${item.id}',
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(editItemRequest.toJson()));
if (response.statusCode == 200) {
return response.body;
}
throw Exception('Could not add item');
}
Future<void> deleteItem(Item item) async {
// TODO: send additional info to be able to access protected endpoint
final http.Response response = await http.delete(
'$itemsApiUrl/${item.id}',
headers: <String, String>{
'Content-Type': 'application/json',
},
);
if (response.statusCode != 204) {
throw Exception('Could not delete item');
}
}
}
The WishlistService
class has the following methods:
getWishlist()
: Returns the wishlist. A successful request is indicated by the 200 status code contained within the response, after which the JSON response is deserialized to a list ofItemDTO
objects. EachItemDTO
object is then “mapped” to an instance of theItem
class. TheWishlist
would then hold all of theItem
s in the user's wishlist returned by the method. The method is asynchronous, so its return type isFuture<Wishlist>
.addItem()
: Adds the item specified by theitem
parameter to the user’s wishlist. Theitem
is mapped to an instance of theAddItemRequestDTO
class. TheAddItemRequestDTO
object needs to be serialized so that it’s in JSON format; this is done by thejsonEncode(addItemRequest.toJson()))
portion of the code.editItem()
: Edits the details of the item with the specified ID. The process is similar to the one inaddItem()
, except theitem
is mapped to an instance of theEditItemRequestDTO
class.deleteItem()
: Deletes the specified item from the user’s wishlist.
In the code above, the value of the constant itemApiUrl
field is set to the value 'YOUR_ITEMS_API_URL'
. Replace this with the URL to the items
endpoint for your Glitch Wishlist API project. Initially, this URL will be of the form https://*project-name*.glitch.me/api/wishlist/items
where project-name is the name of your copy of the project. For example, if your project's name is aardvark-calculator-mousse, you should change the value of itemApiUrl
to https://aardvark-calculator-mousse.glitch.me/api/wishlist/items
.
Later on, you'll be connecting with a version of the API where all the endpoints are protected. This will need additional code, and TODO
comments have been left in the code for this purpose.
Note that all the methods in WishlistService
contain some exception handling code. It's been included for the later version of this project when the mobile app communicates with a secured Wishlist API.
With the data service and data model classes set up, it's time to work on the user interface.
Setting Up the User Interface
The application you'll be building will consist of three pages:
- A landing page that is shown when the user needs to log in
- A page that displays the items in the user’s wishlist
- A page where the user can enter the name, description, and URL of an item to be added to the wishlist or edit the name, description, or URL of an existing wishlist item
Create a pages
directory as a subdirectory of the lib
directory of the project. This will result in a lib/pages
directory, where the code for each page will reside.
To allow for the business logic to be decoupled from each page, the application will be built following the Model-View-ViewModel (MVVM) pattern. Following this pattern enables business logic to be tested in isolation and makes it easier to create reusable widgets, which is what UI components are called in Flutter.
With this application, the views are the pages, and the view models represent abstractions of these pages. There will be a corresponding method in the view model for every command that the user can trigger via the user interface (e.g., pressing a button).
The view model will also contain the data that needs to be presented to the user. For this reason, it’s important for there to be a mechanism for views to subscribe to updates in their corresponding view models. This is where the provider
package will come in, as you'll see later.
The Landing Page
The landing page is where users will log into the application. While the user is in the midst of signing in, it would also be useful to indicate via the user interface that is busy doing so.
We’ll need a place to store the files for the landing page view and view model. With this in mind, create a landing
directory under lib/pages
.
The Landing Page View Model
Create a file for the landing page view model named landing_view_model.dart
in the newly-created lib/pages/landing
directory. Add the following code to the file:
import 'package:flutter/foundation.dart';
class LandingViewModel extends ChangeNotifier {
bool _signingIn = false;
bool get signingIn => _signingIn;
Future<void> signIn() async {
try {
_signingIn = true;
notifyListeners();
await Future.delayed(Duration(seconds: 3), () {});
// TODO: handle signing in
} finally {
_signingIn = false;
notifyListeners();
}
}
}
Classes that extend the Flutter SDK class ChangeNotifier
can notify objects that register as listeners of any changes. By having LandingViewModel
extend ChangeNotifier
, the landing page object can "know" if the user is or is not in the process of signing in, which is indicated by the private instance variable named _signingIn
. This variable is private in order to prevent external code from being able to change its value directly. Instead, only the signingIn
getter is publicly visible, making it a read-only property to outside objects.
The signIn()
method is invoked when the user makes an attempt to sign in. When that happens, we set the _signingIn
instance variable to true
and call ChangeNotifier
's notifyListeners()
method to let the application know that there’s been a change that should be reflected in the user interface.
For now, you'll build a basic version of the mobile app that simulates the process of signing in with an artificial delay created by this line of code:
await Future.delayed(Duration(seconds: 3), () {});
This delay allows you to see how the landing page will look before you implement sign-in functionality.
The Landing Page View
With the landing page view model complete, it's time to work on the corresponding view. Create a file named landing_page.dart
in the lib/pages/landing
directory and enter the following code into it:
import 'package:flutter/material.dart';
import 'landing_view_model.dart';
class LandingPage extends StatelessWidget {
static const String route = '/';
final LandingViewModel viewModel;
const LandingPage(
this.viewModel, {
Key key,
}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Wishlist'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const Text(
'Welcome to your wishlist.',
textAlign: TextAlign.center,
),
const Text(
'Sign in to get started.',
textAlign: TextAlign.center,
),
if (viewModel.signingIn) ...const <Widget>[
SizedBox(
height: 32,
),
Center(child: CircularProgressIndicator()),
],
const Expanded(
child: SizedBox(
height: 32,
),
),
RaisedButton(
onPressed: viewModel.signingIn
? null
: () async {
await signIn(context);
},
child: const Text('Sign in with Auth0'),
),
],
),
),
);
}
Future<void> signIn(BuildContext context) async {
await viewModel.signIn();
// TODO: navigate to wishlist page
}
}
With its view and view model defined, the landing page can now be displayed. In order to do this, you'll need to modify the app's entry point, main.dart
. It's located in the lib
directory.
Replace the code inside main.dart
with the following:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:provider/single_child_widget.dart';
import 'pages/landing/landing_page.dart';
import 'pages/landing/landing_view_model.dart';
Future<void> main() async {
// TODO: change initial route based on if user signed in before
final String initialRoute = LandingPage.route;
runApp(
MultiProvider(
providers: <SingleChildWidget>[
// TODO: register other dependencies
ChangeNotifierProvider<LandingViewModel>(
create: (BuildContext context) => LandingViewModel(),
),
],
child: MyApp(initialRoute),
),
);
}
class MyApp extends StatelessWidget {
final String initialRoute;
const MyApp(
this.initialRoute, {
Key key,
}) : super(key: key);
Widget build(BuildContext context) {
return MaterialApp(
title: 'Wishlist',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
initialRoute: initialRoute,
onGenerateRoute: (RouteSettings settings) {
switch (settings.name) {
case LandingPage.route:
return MaterialPageRoute(
builder: (_) => Consumer<LandingViewModel>(
builder: (_, LandingViewModel viewModel, __) =>
LandingPage(viewModel),
),
);
}
return null;
},
);
}
}
Let's take a look at the code you've just added.
Within the main()
function is an initialRoute
variable that represents the route to present to the user when they first run the application. A route can be thought of as a destination that presents a widget (once again, widgets are UI components).
To register the dependencies used by the code, this is done by using the provider
package. Applications will typically have multiple dependencies that need to be registered. This is represented here via the use of the MultiProvider
widget that wraps around the MyApp
class that represents the application itself. In other words, MyApp
is the child of the MultiProvider
widget. For now, you're registering one dependency, and that is the view model for the landing page. By using the ChangeNotifierProvider
from the provider
package, allows a ChangeNotifier
instance (the LandingViewModel
) to be provided to descendent widgets.
The MyApp
class extends the StatelessWidget
class, which defines a user interface by building a collection of other widgets that provide a more detailed description of the user interface. In this case, MyApp
uses StatelessWidget
's build()
method to return a MaterialApp
widget, which specifies that the app should be built using Google's Material Design.
In instantiating the MaterialApp
widget, the initialRoute
parameter specifies which widget should be shown when the app launches. This will be either the landing page or the wishlist page. The routes
parameter is a Map
that essentially is a lookup table that connects named routes to widgets, each of which represents a page.
When the app starts up, the value passed in the MaterialApp
constructor’s initialRoute
parameter is set to LandingPage.route
. The app then looks up this route in the table provided in the MaterialApp
constructor’s routes
parameter to find the corresponding value, which is a function that builds the landing page and displays it via an animated transition.
You may have noticed that in the routes
table, the page corresponding to LandingPage.route
is wrapped by a Consumer
class. This is a widget from the provider
package, and using it ensures that the landing page has access to its associated view model, LandingViewModel
. The Consumer
widget also helps to re-render the landing page via the builder
property in response to change notifications being sent by its view model.
Structuring the code to manage the dependencies this way provides another benefit. Should a page need to be presented to a user again, a new instance of the associated view model is created. This prevents the application from showing stale data from the last time that page was shown.
You should now be able to run your application. The landing page should look like this:
You can see that there’s an application bar with Wishlist as the title. A welcome message and button docked to the bottom of the screen can be used to sign in. Pressing the button calls the method in the view model that displays a circular progress indicator for 3 seconds.
Right now, you have an app that displays a single page and has the necessary underlying models to manage a wishlist. In the next article, you’ll put those models to work and give the user the ability to read their wishlist, as well as add, edit, and delete wishlist items.