Jahswill Essien explains in this article everything you need to know about GraphQL.
Have you ever faced the task of implementing a REST API and had to call multiple endpoints to populate data for a single screen? You probably wished you had more control over the data returned by the endpoint so that you could fetch more data with a single endpoint call or have only the necessary data fields returned by the call.
Follow along to see how you can achieve this with GraphQL. In this article, we’ll be implementing GraphQL in an existing codebase. We will discuss what GraphQL is, why we chose it, how to implement it with Flutter, and how to implement continuous integration/delivery in our completed app.
This article assumes you are familiar with the Flutter framework and have experience using REST APIs.
We’ll be working on a project called Music Mates, which has a few very simple functionalities:
- Users are asked to select their favorite music artists from a predefined list during signup
- Users should see fellow users who share one or more of their favorite music artists
- Users should see a list of their selected favorite artists
Below is the final result of the application.
Setup
Run this command to clone the project from GitHub to your local machine:
git clone -b widget_design https://github.com/JasperEssien2/music_mates_app
Next, we need to have a proper understanding of the codebase. Let’s walk through the codebase together. Below is the folder structure of the application:
lib/
|- core
| |_ constants.dart
| |_ maths_mixins.dart
|
|- data
| |- model
| | |_ artist.dart
| | |_ user_model.dart
| | |_ home_model.dart
| |
| |_ queries.dart
| |_ data_export.dart
|
|- presentation
| |- widgets
| | |_ error_widget.dart
| | |_ google_button.dart
| | |_ item_artist.dart
| | |_ item_mate.dart
| | |_ item_select_artist.dart
| | |_ loading_spinner.dart
| | |_ mates_ring.dart
| | |_ export.dart
| |
| |_ get_started_screen.dart
| |_ home.dart
| |_ select_favourite_artist.dart
| |_ presentation_export.dart
|
|_ main.dart
Folder structure of the application.
The utility classes used in the codebase are contained in the core
folder. The constants.dart
file contains defined spacing widgets for padding between widgets.
We will need some math functions for the adaptability of the ring widget that displays a user’s music mates. These are defined in the maths_mixin.dart
file. Below is the code snippet for MathsMixin
:
mixin MathsMixin {
/// This method computes the radius of each individual circle in relation
/// to the number of circles required and the radius of the large circle.
/// This helps the circles in the ring to be adaptable, regardless of the number of circles.
double radiusOfSmallCicleInRelationToLargeCircle(
double largeRad, int numberOfCircles) =>
largeRad * math.sin(math.pi / numberOfCircles);
/// Gets the exterior angle of one small circle
double unitAngle(int numberOfPoints) => 360 / numberOfPoints;
}
extension MathsHelper on num {
/// Converts a degree value to a radian value
double get radian => this * math.pi / 180;
}
The data
folder contains our model classes and queries.dart
file. The queries.dart
file contains a queries string document (more on this soon).
The presentation folder contains all of our widgets. The MatesRingWidget
widget in the mates_ring.dart
file is particularly interesting: It displays a user’s music mates in a ring pattern. I’ve added some helpful comments explaining how this was achieved — feel free to check out that file.
These are the dependencies that have been added at this time:
flutter_svg
— used to display .svg filesgoogle_sign_in
— for implementing Google Sign-In
Now that we have an overview of the current codebase, our task is to implement GraphQL
in this application. But what is GraphQL
?
What is GraphQL?
GraphQL is a query language that serves as a contract between the server and the client. It uses its own defined schema, Schema Definition Language (SDL), which was developed by Facebook.
A schema can be thought of as a blueprint that describes the way data is organized. It’s worth noting that GraphQL is not a database technology. Rather, it’s just a language that specifies what data is needed and then sends the request from the client to the back end to resolve the query.
Why GraphQL?
GraphQL has several advantages over traditional REST APIs. This section covers why we chose this technology for this project.
Uses a single endpoint
One of GraphQL’s advantages is its use of a single endpoint as opposed to different endpoints to fetch various data. In comparison, using REST APIs for the Music Mates app can result in a total of about five different endpoints being used:
baseUrl/create-user
baseUrl/all-artists
baseUrl/users/<id>
baseUrl/users/<id>/favourite-artists
baseUrl/users/<id>/music-mates
On the other hand, when using GraphQL, we only need a single endpoint, say baseUrl/graphql
. Weird, isn’t it? The question you’re probably be asking is, “How does GraphQL know what data to return?”
Well, GraphQL organizes data in terms of types and their fields or properties, rather than endpoints, to make sure that clients ask for only what is possible. The fields can be scalars (Int, Float, Boolean, String) or objects that contain properties of their own. An example of a GraphQL schema is shown below:
type Artist{
id: ID!
name: String!
imageUrl: String
description: String!
}
type User{
id: ID!
name: String!
googleId: String!
imageUrl: String
favouriteArtists: [Artist!]
}
Above, we specify the type of schema, the field, and the data type of the field. The !
sign indicates that the field isn’t nullable. A list data type is specified by using square brackets []
and the list content data type.
Fixes the issue of under-fetching/over-fetching
One of the greatest strengths of the GraphQL technology is that it fixes under-fetching and over-fetching. To understand these concepts, take a look at the image below. What data is needed to populate the screen?
Dealing with under-fetching of data
Data on the user’s info, favorite artists, and music mates is needed to populate this screen, right? Using REST APIs, we would have to call an endpoint to fetch the user’s info, another endpoint to fetch their favorite artists, and another to fetch their music mates. This can be referred to as under-fetching because a call for data does not return sufficient data at that point of request.
On the other hand, using GraphQL, we can specify all the required data at once — look at the query below. (For now, don’t worry about trying to understand what’s going on, as this will be explained in detail later.)
query UserInfo(\$googleId: String!){
userInfo(googleId: \$googleId){
id
googleId
name
imageUrl
favouriteArtists {
id
name
imageUrl
description
}
}
musicMates(googleId: \$googleId){
id
googleId
name
imageUrl
}
}
You can retrieve all of the data, including nested data (as shown in the userInfo
block querying favouriteArtists
), with just one call. Isn’t that beautiful? That’s the power of GraphQL.
Dealing with over-fetching of data
Take a look at our home screen again. For the circle avatar widget that displays the user’s image, we need only the user’s image property from the data. For the artist item, we need just the artist’s name, image, and description properties.
With REST APIs, we have no control over what field should be returned. Instead, all of the data properties specified by the endpoint are returned. This is called over-fetching — fetching data properties that aren’t needed.
GraphQL gives us control over what field should be returned by the API. When we refactor the above query to suit our current widget’s needs, we have:
query UserInfo(\$googleId: String!){
userInfo(googleId: \$googleId){
imageUrl
favouriteArtists {
name
imageUrl
description
}
}
musicMates(googleId: \$googleId){
imageUrl
}
}
Querying data with the above query returns only the data needed to satisfy our widget. Incredible, right?
GraphQL is faster
Due to GraphQL’s flexibility in regard to retrieving specific fields when querying data, it is faster than its counterpart, REST.
Built-in state management mechanism
The plugin we’ll use for GraphQL in our Flutter app has a state management mechanism built in, which we can use to update our UI based on the current state of the query.
Before implementing GraphQL, let’s set up other classes that will be helpful to us. Head to the lib/presentation
folder and create a file called query_document_provider.dart
. The goal here is to inject our queries document down the widget tree to be accessible to children widgets using InheritedWidget
.
Before implementing QueriesDocumentProvider
, it is important to understand what InheritedWidget
is. It is a widget that propagates data down the widget tree. It utilizes BuildContext
to achieve this.
BuildContext
is a class in Flutter that keeps track of “this” widget location in the widget tree. Each widget has its own BuildContext
, but a widget BuildContext
only keeps track of its direct parent BuildContext
.
When you call Scaffold.of(context)
or Navigator.of(context)
, Flutter moves up the widget tree using the context and finds the nearest Scaffold
or Navigator
widget. Keep in mind that the widget BuildContext
has a bottom-up relationship with other widgets’ BuildContext
.
import 'package:flutter/scheduler.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:music_mates_app/data/data_export.dart';
class QueriesDocumentProvider extends InheritedWidget {
const QueriesDocumentProvider(
{Key? key, required this.queries, required Widget child})
: super(key: key, child: child);
final MusicMateQueries queries;
static MusicMateQueries of(BuildContext context) {
final InheritedElement? element = context
.getElementForInheritedWidgetOfExactType<QueriesDocumentProvider>();
assert(element != null, 'No MusicMateQueries found in context');
return (element!.widget as QueriesDocumentProvider).queries;
}
@override
bool updateShouldNotify(QueriesDocumentProvider oldWidget) =>
queries != oldWidget.queries;
}
We then add an extension
to the BuildContext
to provide our additional functionalities to the BuildContext
class.
extension BuildContextExtension on BuildContext {
/// Enables us to use context to access queries with [context.queries]
MusicMateQueries get queries => AppProvider.of(this);
/// Use context to show material banner with [context.showError()]
void showError(ErrorModel error) {
SchedulerBinding.instance?.addPostFrameCallback((timeStamp) {
var theme = Theme.of(this);
ScaffoldMessenger.of(this).showMaterialBanner(
MaterialBanner(
backgroundColor: theme.colorScheme.primary,
contentTextStyle:
theme.textTheme.headline5!.copyWith(color: Colors.white),
content: Text(error.error),
actions: [
InkWell(
onTap: () => ScaffoldMessenger.of(this).clearMaterialBanners(),
child: const Icon(Icons.close, color: Colors.white),
)
],
),
);
});
}
}
Implementing GraphQL
To begin implementing GraphQL in our Flutter app, we need to add the GraphQL dependency for Flutter. To do that, run this command on your terminal:
flutter pub add graphql_flutter
In the main.dart
file, change the MyApp
widget from a StatelessWidget
to a StatefulWidget
.
To connect our Flutter app to the GraphQL server, we need to create a GraphQLClient
, which requires a Link
and a GraphQLCache
. The instance of GraphQLClient
is passed to a ValueNotifier
. This is also a good time to create an instance of MusicMateQueries
.
The cache passed is what GraphQL uses to cache the result. By default,
GraphQLCache
usesInMemoryCache
, which does not persist to local storage. To persist the cache to local storage, initialize theGraphQLCache
store parameter toHiveStore
like this:GraphQLCache(store: HiveStore());
final GraphQLClient client = GraphQLClient(
link: HttpLink('https://music-mates-fun.herokuapp.com/graphql'),
cache: GraphQLCache(),
);
late final ValueNotifier<GraphQLClient> clientNotifier =
ValueNotifier<GraphQLClient>(client);
final queries = MusicMateQueries();
Next, wrap the MaterialApp
with GraphQLProvider
. This is important because using Query
or Mutation
widgets requires a client, and this provider is responsible for passing an instance of GraphQLClient
down the widget tree. Pass an instance of ValueNotifier<GraphQLClient>
to GraphQLProvider
so that widgets can be notified when data changes.
Also, wrap the GraphQLProvider
with our own QueriesDocumentProvider
. The build()
method will be updated to the code snippet below:
@override
Widget build(BuildContext context) {
return QueriesDocumentProvider(
entity: queries,
child: GraphQLProvider(
client: clientNotifier,
child: MaterialApp(
title: 'Music Mates',
theme: _themeData(context),
home: const GetStartedScreen(),
routes: {
Routes.home: (context) => const HomeScreen(),
Routes.selectArtist: (context) => const SelectFavouriteArtist(),
},
),
),
);
}
Add the line of code below to the BuildContextExtension
extension to easily access an instance of GrapQLClient
by calling context.graphQLClient
:
/// Enables us to use context to access an instance of [GraphQLClient] with [context.graphQlClient]
GraphQLClient get graphQlClient => GraphQLProvider.of(this).value;
/// Take advantage of the GraphQL cache to cache the user's Google ID
/// to be used across the app
void cacheGoogleId(String googleId) {
graphQlClient.cache.writeNormalized('AppData', {'googleId': googleId});
}
/// Retrieves current user's Google ID from the cache
String get retrieveGoogleId =>
graphQlClient.cache.store.get('AppData')!['googleId'];
The docs state that using the cache directly (
graphQLClient.cache
) isn’t ideal, as changes won’t be broadcasted immediately. It’s preferable to useclient.writeQuery
andclient.writeFragment
to those on theclient.cache
for automatic rebroadcasting. In this case, usingcache
directly is more of a hack to cache the Google ID coming from outside our database. (It comes from the Google Sign-In package.)
Performing CRUD operations with GraphQL
GraphQL uses the query
keyword for read operations and the mutation
keyword for create, update, and delete operations.
Look at this flowchart of the app’s process to better understand what is expected of us.
Query
Firstly, go to the lib/data/queries.dart
file. Under fetchAllArtist()
, return the query string below:
"""
query {
allArtists {
id
name
imageUrl
description
}
}
"""
We use the query
keyword to signify that this is a query request. allArtists
is the root field, and everything else that follows is the payload.
Add a fromJson
constructor in the ArtistModel
class. This will be responsible for parsing the artist JSON data.
You can locate the
ArtistModel
class in this file path:lib/data/model/artist.dart
ArtistModel.fromJson(Map<String, dynamic> data)
: name = data['name'],
id = data['id'] == null ? null : int.parse(data['id']),
imageUrl = data['imageUrl'],
description = data['description'];
Since we’ll be dealing with a list of artists, it makes sense to add another model class named ArtistList
that will be responsible for parsing a list of artist JSON data to a list of ArtistModel
.
class ArtistList {
final List<ArtistModel> artists;
ArtistList.allArtistFromJson(Map<String, dynamic> json)
: artists = json['allArtists']
.list
.map((e) => ArtistModel.fromJson(e))
.toList();
}
In fetchUserInfo()
, return the string query below:
"""
query UserInfo(\$googleId: String!){
userInfo(googleId: \$googleId){
name
imageUrl
favouriteArtists {
name
imageUrl
description
}
}
musicMates(googleId: \$googleId){
name
imageUrl
}
}
"""
The above is a query with variables. The UserInfo($googleId: String!)
following the query
keyword defines a variable (which is needed by that query) and its data type to be used across the nested payloads.
The nested userInfo(googleId: $googleId)
is the root field for fetching the user’s info. Then, the googleId
variable is passed along the query because the query requires it. The same thing applies to the musicMates
field.
Now that we have a glimpse of the user JSON structure, it’s also a good time to add a fromJson()
constructor in the UserModel
class, which will be responsible for parsing user JSON data to the model class.
You can locate the
UserModel
class in this file path:lib/data/model/user_model.dart
UserModel.fromJson(Map<String, dynamic> data)
: name = data['name'],
imageUrl = data['imageUrl'],
favouriteArtist = data['favouriteArtists'] == null
? []
: (data['favouriteArtists'] as List<dynamic>?)
?.map((e) => ArtistModel.fromJson(e))
.toList();
Also, add a UserList
model to parse the list of users.
class UserList {
final List<UserModel> users;
UserList.musicMatesJson(Map<String, dynamic> data)
: users = (data["musicMates"] as List)
.map((e) => UserModel.fromJson(e))
.toList();
}
Since the query on the home page fetches all the data we need at once, we need to have a central model for our home screen. Update the HomeModel
class in lib/data/model/user_model.dart
with the code snippet below:
import 'package:music_mates_app/data/data_export.dart';
class HomeModel {
final UserModel currentUser;
final List<UserModel> musicMates;
HomeModel.fromJson(Map<String, dynamic> json)
: currentUser = UserModel.fromJson(json['userInfo']),
musicMates = UserList.musicMatesJson(json).users;
}
You might notice that something is amiss in the string queries above — where are those root fields (userInfo
and musicMates
) coming from? Or are they just arbitrary field names? Well, recall that in the beginning of this article, it was mentioned that graphql
is a contract/blueprint between the front end and back end. So there must be back-end code.
The back end receives our request and resolves the query. Take a peek at the back-end code for the queries below:
Note: I’m using Django with GraphQL for the back end due to my familiarity with Python. However, you can also use GraphQL technology with other back-end technologies, like Node.js. Additionally, the code above might not be perfect — this is my first time writing back-end code, so forgive any irregularities.
Next, head over to the lib/presentation/widgets
folder, create a file named query_wrapper.dart
, and insert the code below. The goal is to make a reusable widget that we can use across our app to avoid duplication of code. Make sure to read the helpful comments in the code snippet below.
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:music_mates_app/data/data_export.dart';
import 'package:music_mates_app/presentation/widgets/export.dart';
class QueryWrapper<T> extends StatelessWidget {
const QueryWrapper({
Key? key,
required this.queryString,
required this.contentBuilder,
required this.dataParser,
this.variables,
}) : super(key: key);
/// Query parameters meant to be passed alongside the query
final Map<String, dynamic>? variables;
/// The query string document to request from the GraphQL endpoint
final String queryString;
/// This callback method is responsible for building our UI and passing the data
final Widget Function(T data) contentBuilder;
/// This callback receives the JSON data in the form of a map and then
/// parses the data to our model class
final T Function(Map<String, dynamic> data) dataParser;
@override
Widget build(BuildContext context) {
return Query(
options: QueryOptions(
fetchPolicy: FetchPolicy.cacheAndNetwork,
document: gql(queryString),
variables: variables ?? const {},
parserFn: dataParser,
),
builder: (QueryResult result,
{VoidCallback? refetch, FetchMore? fetchMore}) {
if (result.isLoading) {
return const LoadingSpinner();
}
if (result.hasException) {
return AppErrorWidget(
error: ErrorModel.fromString(
result.exception.toString(),
),
);
}
return contentBuilder(result.parserFn(result.data ?? {}));
},
);
}
}
In the above code, we define a generic parameter of type T
for the widget. The generic parameter T
is a placeholder for the type of data model we expect. (Read on for clarification on this.)
To harness the reactive nature of GraphQL queries with Flutter, we use the Query<TParsed>
widget, which is a generic widget. It accepts two parameters: options
and builder
.
options
The options
parameter is where configurations are set for “this” query call. It is an instance of the QueryOptions
class, which accepts some parameters. Specifically, we will be setting the fetchPolicy
, document
, parserFn
, and variables
parameters.
- The
document
parameter is where our query string will go, but it requires an instance ofDocumentNode
. We will have to usegql()
, which is provided by the GraphQL package and has the responsibility of parsing the string to aDocumentNode
. variables
is aMap<String, dynamic>
containing our query param, mapped to its value.parserFn
takes the JSON data format and parses it to a model class of a specified type.fetchPolicy
is a parameter that requires anenum
of typeFetchPolicy
. This dictates how fetching is done between cache and remote data. Below is an explanation of how each value affects the query process.FetchPolicy.cacheFirst
prioritizes cached data and only fetches from remote data when cached data isn’t available.FetchPolicy.cacheAndNetwork
queries for local data first, if it is available. It then fetches from remote data and returns it.FetchPolicy.cacheOnly
, as the name implies, fetches from cached data and fails if no data is available.FetchPolicy.noCache
fetches remote data but does not cache any data returned.FetchPolicy.networkOnly
prioritizes fetching from remote data but still saves data to the cache.
builder
The builder
is a callback method that gets called when a query request is triggered, when the request is successful, and when the request fails. Its return type is a Widget
.
The builder has three parameters:
result
:- an instance ofQueryResult
that has properties for the current state.QueryResult.isLoading
is set to true when the request is still in progress. This is a good place to display a progress bar.QueryResult.hasException
is set to true when an error occurs while processing a request.QueryResult.data
holds the response data returned by theGraphQL
endpoint. It’s of typeMap<String, dynamic>
.QueryResult.parseFn
parses the data in JSON format to the specified model.
refetch
is a void callback method that should be called to instruct the query to make a request to theGraphQL
endpoint again.fetchMore
is a callback that can be utilized to handle the pagination of data.
To utilize QueryWrapper
, replace the ListView
in the SelectFavouriteArtist
widget with the code below:
This widget can be found in
lib/presentation/select_favourite_artist.dart
.
QueryWrapper<ArtistList>(
queryString: context.queries.fetchAllArtist(),
dataParser: (json) => ArtistList.allArtistFromJson(json),
contentBuilder: (data) {
final list = data.artists;
return ListView.builder(
itemCount: list.length,
physics: const BouncingScrollPhysics(),
itemBuilder: (c, index) {
var artist = list[index];
return ItemSelectArtist(
artist: artist,
onTap: () => onTap(artist.id!),
isSelected: selectedArtist.contains(artist.id),
);
},
);
},
)
Also, replace the _HomeScreenState
scaffold body with the code below.
QueryWrapper<HomeModel>(
queryString: context.queries.fetchUserInfo(),
dataParser: (json) => HomeModel.fromJson(json),
variables: {
'googleId': context.retrieveGoogleId,
},
contentBuilder: (data) {
return _Content(model: data);
},
)
Mutation
The mutation
keyword is used to signify that a request is to create, update, or delete data. Let’s specify our mutation string in the lib/data/queries.dart
file. Under the createAccount()
method, return the following string:
"""
mutation createUser(\$name: String!, \$googleId: String!, \$imageUrl: String!, \$favouriteArtists: [ID]){
createUser(name: \$name, googleId: \$googleId, imageUrl: \$imageUrl, favouriteArtists: \$favouriteArtists){
user{
name
imageUrl
favouriteArtists {
name
description
imageUrl
}
}
}
}
"""
Under updateUser()
, return the string code snippet below.
"""
mutation UpdateUser(\$name: String, \$googleId: String!, \$imageUrl: String, \$favouriteArtists: [ID]){
updateUser(name: \$name, googleId: \$googleId, imageUrl: \$imageUrl, favouriteArtists: \$favouriteArtists){
user{
name
imageUrl
favouriteArtists {
name
description
imageUrl
}
}
}
}
"""
We can see that the mutation string is very similar to the query string. The only difference between them is their keyword, as mutation uses the mutation
keyword.
To see this in action, let’s start by implementing the creation of a user. Head to the lib/presentation/get_started_screen.dart
file in the _GetStartedScreenState
state, and wrap the GoogleButton
with the code below:
Mutation(
options: MutationOptions(
document: gql(context.queries.createAccount()),
onCompleted: (data) => _onCompleted(data, context),
),
builder: (RunMutation runMutation, QueryResult? result) {
if (result != null) {
if (result.isLoading) {
return const LoadingSpinner();
}
if (result.hasException) {
context.showError(
ErrorModel.fromGraphError(
result.exception?.graphqlErrors ?? [],
),
);
}
}
return GoogleButton(
onPressed: () => _googleButtonPressed(context, runMutation),
);
},
)
Below is the _onCompleted()
method implementation:
void _onCompleted(data, BuildContext context) {
/// We get the user's favorite artist field from the data
final favouriteArtists = data['createUser']['user']['favouriteArtists'];
/// Check if the user has selected artists
final bool hasSelectedArtist =
favouriteArtists != null && favouriteArtists.isNotEmpty;
/// If they do, move to home page. If not, take them to select artist page for them to select artists.
Navigator.popAndPushNamed(
context,
hasSelectedArtist ? Routes.home : Routes.selectArtist,
);
}
Let’s analyze the code. Similar to the Query
widget, the Mutation
widget takes in two parameters, options
and builder
.
options
The options
parameter is where configurations are set for “this” mutation calls. It is an instance of the MutationOptions
class, which has some similar parameters to QueryOptions
and more. Specifically, we set the document
param, which is mandatory, and the onCompleted
callback method, which is called when a mutation request is made.
builder
The builder
is a callback method that gets called when a mutation request is triggered, when the request is successful, and when the request fails. Its return type is a Widget
.
It has two parameters. runMutation
accepts a Map<String, dynamic>
parameter where you passed mutation query variables. It is an instance of RunMutation
. On the other hand, result
contains the current state of the request (this was discussed in the query section). It is an instance of QueryResult
.
Calling runMutation
triggers the request. To see this in action, let’s implement the _googleButtonPressed()
method.
Future<void> _googleButtonPressed(
BuildContext context, RunMutation runMutation) async {
/// Use the Google Sign-In package to initiate the Google Sign-In
final googleUser = await googleSignin.signIn();
if (googleUser == null) return;
/// Cache the Google ID to be used across the app
context.cacheGoogleId(googleUser.id);
/// Trigger the mutation query by calling [runMutation] and passing in the mutation variable
runMutation(
{
'name': googleUser.displayName,
'googleId': googleUser.id,
'imageUrl': googleUser.photoUrl!,
'favouriteArtists': [],
},
);
}
To see how to use mutation for updating, head over to the _DoneButton
widget in lib/presentation/select_favourite_artist.dart
, and return the code below in the build()
method.
Mutation(
options: MutationOptions(
document: gql(context.queries.updateUser()),
onCompleted: (_) => Navigator.popAndPushNamed(context, Routes.home),
),
builder: (RunMutation runMutation, QueryResult? result) {
if (result!.isLoading) return const LoadingSpinner();
if (result.hasException) {
context.showError(ErrorModel.fromString(result.exception.toString()));
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 35),
child: TextButton(
style: ButtonStyle(
enableFeedback: isEnabled,
backgroundColor: MaterialStateProperty.all(
Colors.grey[300],
),
),
onPressed: () => _onButtonPressed(isEnabled, runMutation, context),
child: SizedBox(
width: double.infinity,
child: Center(
child: Opacity(
opacity: isEnabled ? 1 : 0.2,
child: const Text(
"DONE",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
),
);
},
)
The implementation of the method _onButtonPressed()
is shown below:
void _onButtonPressed(
bool isEnabled, RunMutation<dynamic> runMutation, BuildContext context) {
if (isEnabled) {
runMutation(
{
'googleId': context.retrieveGoogleId,
'favouriteArtists': selectedArtist,
},
);
} else {
context.showError(
ErrorModel.fromString("Please select your favorite artist"),
);
}
}
We followed the same process for updating the user’s favorite artist as for creating a user. So how does GraphQL know when to update or create data? You’ll probably answer correctly that the power lies in the back end doing all the work of resolving queries and mutation requests. Take a peek at the back-end code below:
Note: I’m using Django with GraphQL for the back end due to my familiarity with python. However, you can also use GraphQL technology with other back-end technologies, like Node.js. Additionally, the code above might not be perfect — this is my first time writing back-end code, so forgive any irregularities.
Subscription
While we won’t need to implement a subscription for our app, it’s very important to know how this works. Subscribing to a GraphQL endpoint means listening to changes and reacting when a change occurs. To achieve this, it uses WebSockets and Dart Stream.
Subscriptions use a similar format with a query, but this type uses subscription
as the keyword. It uses the Subscription
widget as opposed to the Query
widget for the query.
If we want to use a subscription, the link has to be different. But not all of our requests are WebSocket links. So how do we achieve this? We can do this by splitting the subscription-consuming link from the HttpLink
.
link = Link.split((request) => request.isSubscription, websocketLink, link);
It accepts options
, an instance of SubscriptionOptions
. The builder
callback has a result
parameter, which we’ve discussed already.
Wrap the child widget with ResultAccumulator
, which is a helper widget provided by the graphql_flutter
package, to combine subscription results without having to do the work yourself.
ResultAccumulator.appendUniqueEntries(
latest: result.data,
builder: (context, {results}) => ChildWidget(
reviews: results.reversed.toList(),
),
)
Continuous integration/delivery with Codemagic (CI/CD)
CI/CD is automates deployment processes so that developers can focus on product requirements and the quality of the code.
Continuous integration deals with building and testing applications when there’s an update in the codebase.
Continuous delivery automates the deployment or publishing of apps to various platforms, like Google Play Store, Apple App Store, TestFlight, and Firebase.
Codemagic is a CI/CD tool that works excellently with Flutter. Some of its cool features are highlighted below:
- Handles building both Android (.apk/.aab) and iOS (.ipa) files
- Very easy to set up
- Supports different Git version control platforms, like GitHub, Bitbucket, and GitLab
- Works with a variety of triggers
- Good error reporting when something goes wrong during the build
Codemagic setup
Before setting up your app, you need to sign your Android application.
Sign in to Android application
Please note: If you already know how to sign in to an Android application, you can skip the steps below.
Android applications require two signing keys. Signing is a way of giving the app digital signatures. The first time you need to sign is when you are uploading to the Play Store — here, you’ll use the upload key. The second time is after deployment — you will need to use the deployment key to sign the .apk/.aab file.
To generate an upload key, run one of the commands below:
For Mac/Linux:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias myAliasKey
For Windows:
keytool -genkey -v -keystore c:\Users\USER_NAME\upload-keystore.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias myAliasKey
When you run one of these commands, you are required to input some information. Bear in mind that information like the store password and key password will be needed. After you’re done with this step, the command generates an upload-keystore.jks
file on your home page.
You can choose to change the path to store the file, the
.jks
file name, and the alias key.
Under the android
folder, create a file called key.properties
. Then insert the code below, replacing <>
with the actual value.
storePassword=<Store password>
keyPassword=<Key password>
keyAlias=<Alias key name>
storeFile=<Path to .jks file>
This file is a private file and SHOULDN’T be committed to your public source control. Search for
.gitignore
and add yourkey.properties
file to tell Git to ignore this file.
The next step is to utilize the upload key to sign the app. Head over to the /android/app/build.gradle
file and insert the code below:
/// This loads the key.properties file
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
Search for the “buildTypes” code block, and replace the content with the code below:
signingConfigs {
release {
storeFile file(keystoreProperties['storeFile'])
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
The above code utilizes the key.properties
file. Any time the app is run or is built on release mode, the upload key is used to sign the app. The sign-in for an iOS application can be done automatically with Codemagic.
To create a Codemagic account, visit https://codemagic.io/signup, and then choose an option. Preferably, sign up with the version control platform where you store this application codebase so that Codemagic can easily pick it up.
You’ll be prompted to add an application. You can either input the Git URL of the project you want to set up or select a Git provider so that Codemagic can load your repository.
Select your repository, and select Flutter App for the project type.
Multiple workflows can be configured. Workflows come in handy when you have different release environments, like production and development environments.
Build triggers setup
This section deals with setting up Git actions that will trigger Codemagic to start building your application. You have the option of triggering a Codemagic build on push, pull request update, or tag creation. You can attach these triggers to a specific branch target.
We want Codemagic to trigger on push and pull requests, and our target branch is the master branch.
Test setup
To ensure that all test cases pass before publishing your app, ensure that you allow Codemagic to run tests for each build by checking the Enable Flutter test field. You can also enable Static code analysis.
Build setup
Select Android App Bundle (AAB) as the Android build format. The build mode should be set to Release.
Build arguments are where you pass arguments to run along with the build command. In our case, we want the app version to increment for each build, so we set the build arguments for both iOS and Android as --build-name=1.0.$PROJECT_BUILD_NUMBER --build-number=$PROJECT_BUILD_NUMBER
.
Distribution setup
Android code signing
Insert your upload signing configuration in the Android code signing tab. This includes the .jks
file that was created, as well as the keystore password, key alias, and key password.
Google Play
In the Google Play tab, check the Enable Google Play publishing checkbox. Select the internal track and check Submit release as drafts to build and upload to the Google Play Console as a draft. Then we can manually release the app to internal testers. You can learn about each of these tracks here.
The reason you need to select the internal track is because before releasing to production, you must have published an internal track application and answered a series of questions about your application.
Follow these steps to get the credentials (.json). Note that you must have a Google Play account to proceed.
In Google Play Console
- Go to the Google Play Console and click on the Create app button.
- Click on the API access tab. Then click on the Create new service account button.
- In the dialog, tap on the Google Cloud Platform link.
In Google Cloud Platform
- Click on the menu, and go to IAM & Admins > Service Accounts.
- Select a project, and then select the Google Play Console Developer link in the dialog that pops up.
- Click Create service account.
According to the documentation, a service account credential is used to authenticate as a robot service account or to access resources on behalf of Google Workspace or Cloud Identity users through domain-wide delegation.
- Fill in the service account details. Then click Create and continue.
- Assign a role to the service account.
- Click Done.
- Click on the service account you just created. It should look like the image below.
- Click Keys > Add Keys > Create new key.
- You’ll be prompted with the dialog shown below. Select JSON, then click Create.
If you completed the above steps correctly, a .json
file should be downloaded automatically. Then upload the .json
file in the Credentials tab on the Codemagic portal. Make sure to save your changes.
iOS code signing
To enable this section, you’ll have to connect your Apple Developer Portal account. Tap on the Go to settings button and follow the instructions to connect your account.
Head back to the Google Play Console on the API access tab. Under Service accounts, confirm that the service account you just created was added, and then click on Grant permissions. If you don’t see the service account, refresh the page.
Under Permission, tap on the App permissions tab, click on the Add app dropdown, and select the Google Console app you created.
Now you can manually trigger a build by clicking on the Start new build button. Before triggering, ensure that:
- The Kotlin version is equal to or above
1.6.0
by heading to/android/build.gradle
under thebuildScript
code block. - Artifacts have been uploaded manually at least once for the first upload. If not, a
404: Package not found error
will be thrown by Codemagic. - Each app version must be different from the previous app version.
The same principles apply to publishing in the production track, though you have to complete a series of questions concerning your app on the Google Play dashboard.
At this stage, we can only trigger the build manually. However, we can also do this automatically. The goal is to trigger Codemagic to start a build when there’s a push to the master branch.
Webhooks allow you to send real-time data from one application to another in response to an event. On our Codemagic app details page, click on the Webhooks tab, and copy the URL shown there.
Head to your repository settings on GitHub. In the left panel, click Webhooks and insert the URL you just copied to the Payload URL field. Then click the Add webhook button. Now, any time a push occurs on the master branch, Codemagic triggers a build.
That was a lot to process! This section focused on how to sign our apps, the purpose and benefits of CI/CD, and how to use and configure CI/CD with Codemagic.
Conclusion
I hope you had fun building the Music Mates app! To see the completed code, run git checkout master
in your terminal. While this was probably a lot to take in, we’ve learned so much about GraphQL, what makes it powerful, how we can utilize it in Flutter, and how to harness the reactive nature of flutter_graphql. And we didn’t stop there — we took our challenge two steps further up the ladder by implementing continuous integration/delivery and learning how to sign our applications and prepare for release. Hopefully, this will give you all the knowledge you need to start building your applications with GraphQL and handling your continuous integration/delivery with Codemagic.
Jahswill Essien is a mobile developer, ardent on building awesome stuff with Flutter, he has been using Flutter for the past 2 years. Jahswill is a visual learner as such he strives to write articles that contain pictorial illustrations and/or analogies to drive home a concept explained. His favourite quote is: “We are constrained by our mind, what we feel is our limit isn’t our limit. It’s amazing how much we can learn in a short amount of time by perseverance alone”. Check out some things Jahswill built with flutter by visiting his Github profile.