Things to keep in mind

What you'll learn

Prerequises

  1. A bit of Flutter knowledge. The Flutter team codelabs and the cookbook should help you get started.
  2. Knowledge of Riverpod and the Freezed packages. I do cover them a bit in this article.
  3. A bit a GraphQL knowledge but not required for this codelab.

GraphQL

GraphQL is a query language to structure and manage your backend server. It consist of having a schema file (shema.graphql) which is the rules and instructions that your server will need to abide by. Your schema file will also help you define what your client application (which in our case the flutter app) can request of it.

There are many Graphql services available and each have their own methods to launch your own server but we will use Dgraph, the main reason is because it a native backend build from the ground up for GraphQL. It's not build on top an already exiting database which most of them do. That what make up for speed and performance.

To start your Dgraph server you'll need to install the Docker engine.

Once installed, open a termimal and run sudo docker pull dgraph/standalone:latest to download image. Then run sudo docker run -it -p 8080:8080 dgraph/standalone:latest to start the server.

Two endpoints will be avalaible to you:

The admin endpoint is where you will upload your GraphQL schema. The GraphQL endpoint is where we send queries to the server to get a response.

Before sending queries you'll need to have a schema, open a new termimal and create a new file in your desired location then touch schema.graphql then nano schema.graphql and copy this schema to your file and save it:

type User {
  id: ID!
  name: String!
  description: String
}

To push your schema to the admin endpoint, you'll need to install curl sudo apt install curl then run curl -X POST localhost:8080/admin/schema --data-binary '@schema.graphql'. If it works, you should see the output {"data":{"code":"Success","message":"Done"}}.

The schema you pushed to the server is small but a lot is happening to the backend. First of all, the schema we send is not the actual schema. Dgraph generated the real schema, it added all the boilerplate code to all of your possible queries using the User type. The only thing you need to worry is to build types.

To explain the schema file, we created a User type that will hold an id, a name and a description. Next to the ":" is the object type and "!" means that it can't be null. So if we need to add a user for example, I will need to provides these variables with the exception of description that is nullable. Also, the ID type is also generated for you when adding a User so you don't actually need to specify one.

In GraphQL, when writting queries they are three root types mutation, query and subscription. The mutation is everything that writing to the database with "add/delete/update", query is about reading the database "get" and subscription is for realtime long lasting notification results.

The Dgraph server is running and your sample schema is uploaded. You can now make some queries and mutations. To summarize the following, you'll use a http tool to write your requests in GraphQL and see the results. This will help you with the Flutter app when sending requests later.

Testing your query code

We will be using the Thunderclient extension to test our server. In VSCode search the extension and install it. install_thunderclient

Click on the thunder logo, then on "New Request". Change the "Get" dropdown to "Post". Replace https://www.thunderclient.com/welcome with your GraphQL endpoint http://localhost:8080/graphql. Under the link, Change the tab "Query" to "Body". Another row of tabs should be visible then choose tab "Graphql". Your setup should look like this. setup_thunderclient

You are now ready! We will test four GraphQL queries (3 mutation and 1 query) to prepare our Flutter app. Once you write the request in the "Query" box you can click "Send" to receive the json response for each query. You can also format the query you wrote above the "Query" box.

mutation {
  addUser(input: {name: "John", description: "Doe"}) {
    user {
      id
      name
      description
    }
  }
}
query {
  getUser(id: "0x25") {
    id
    name
    description
  }
}

mutation {
  updateUser(
    input: {filter: {id: ["0x25"]}, set: {name: "update John", description: "update Doe"}}
  ) {
    user {
      id
      name
      description
    }
  }
}

mutation {
  deleteUser(filter: {id: ["0x25"]}) {
    msg
    user {
      id
      name
      description
    }
  }
}

You tested your queries against the GraphQL server using Thunderclient. You are now ready to build your Flutter app that will connect to the local server.

This is the app you will build, each request type are in tabs and when you tap on the button you will see the request you did on the bottom half of the screen depending on what you enter in the text fields:

graphql_sample_01

graphql_sample_02

This will be the folder structure for the app starting from lib:

📂lib
 ┣ 📂graphql
 ┃ ┣ 📜add_user.graphql
 ┃ ┣ 📜delete_user.graphql
 ┃ ┣ 📜get_user.graphql
 ┃ ┗ 📜update_user.graphql
 ┣ 📂model
 ┃ ┗ 📜user.dart
 ┣ 📂view
 ┃ ┣ 📜user_form_fields.dart
 ┃ ┗ 📜user_view.dart
 ┣ 📜graphql_client.dart
 ┗ 📜main.dart

You will start by adding all the dependencies for riverpod and freezed, copy this line and add it to the Terminal:

flutter pub add flutter_riverpod riverpod_annotation freezed_annotation json_annotation && flutter pub add -d build_runner custom_lint riverpod_generator riverpod_lint freezed json_serializable

The "graphql" folder in your file structure will contains the queries you tested in the Thunderclient extension. However, in order for the Flutter app to read those queries you need to add variables on top, so Flutter can pass those values. Here you'll need to know your Dgraph autogenerated types but the generated pattern is the same in whatever type you create in your schema. This will be queries in each file:

vscode_client_queries The "$variableName" will be the name that you will use in your dart code to pass the values for your requests.

Your GraphQL files will serve as assets in your code to read the string, so you must declare the assets at the end of your pubspec.yaml file :

  assets:
    - lib/graphql/

To handle the requests you'll need to add the graphql dependency, you can do so with flutter pub add graphql.

Following along the folder structure we will add this code in yourgraphql_client.dart file:

import 'package:graphql/client.dart';


GraphQLClient graphQLClientInit() {


final httpLink = HttpLink(
  'http://localhost:8080/graphql',
);

final authLink = AuthLink(
  getToken: () async => '', //'Bearer $YOUR_PERSONAL_ACCESS_TOKEN',
);

Link link = authLink.concat(httpLink);

return GraphQLClient(
  cache: GraphQLCache(),
  link: link,
);
}

In the main.dart file, you will declare your provider for your graphql client. Here what main will look like:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql/client.dart';
import 'package:graphql_sample/graphql_client.dart';
import 'package:graphql_sample/view/user_view.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

@riverpod
GraphQLClient graphQLClient(GraphQLClientRef ref) {
  return graphQLClientInit();
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter GraphQL Demo',
      theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
      ),
      home: const HomeView(),
    );
  }
}

Now that you got the client setup in a provider, you can now start building the model using the @freezed annotation and @riverpod annotation for the state notifier. Add the following code in your user.dart:

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
// hide the JsonSerializable as there is a library conflict with json_annotation
import 'package:graphql/client.dart' hide JsonSerializable;
import 'package:graphql_sample/main.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user.freezed.dart';

part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required String id,
    required String name,
    required String? description,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}

@riverpod
class UserQueries extends _$UserQueries {
  late final GraphQLClient client;
  String? status;

  @override
  User build() {
    client = ref.read(graphQLClientProvider);
    return const User(id: 'null', name: 'null', description: 'null');
  }

  Future<void> addUser({required String name, String? description}) async {
    final addUserMutation =
        await rootBundle.loadString('lib/graphql/add_user.graphql');

    final MutationOptions options = MutationOptions(
      document: gql(addUserMutation),
      variables: <String, dynamic>{
        // the variable put here must match the query variable ($user)
        'user': {
          'name': name,
          'description': description,
        }
      },
    );

    final QueryResult result = await client.mutate(options);

    if (result.isLoading && result.data != null) {
      status = 'Loading';
    }

    if (result.hasException) {
      debugPrint(result.exception.toString());
      status = 'Error (See Logs)';
    }

    debugPrint('ADD USER: ${result.data}');

    final userJson = result.data?['addUser']['user'][0];

    debugPrint('ADD USER TRIM DOWN: $userJson');

    final User user = User.fromJson(userJson);

    status = 'Added User';

    state = state.copyWith(
      id: user.id,
      name: user.name,
      description: user.description,
    );
  }

  Future<void> deleteUser({required String id}) async {
    final deleteUserMutation =
        await rootBundle.loadString('lib/graphql/delete_user.graphql');

    final MutationOptions options = MutationOptions(
      document: gql(deleteUserMutation),
      variables: <String, dynamic>{
        // the variable put here must match the query variable ($filter)
        'filter': {
          'id': id,
        }
      },
    );

    final QueryResult result = await client.mutate(options);

    if (result.isLoading && result.data != null) {
      status = 'Loading';
    }

    if (result.hasException) {
      debugPrint(result.exception.toString());
      status = 'Error (See Logs)';
    }

    debugPrint('DELETE USER: ${result.data}');

    final userJson = result.data?['deleteUser']['user'][0];

    debugPrint('DELETE USER TRIM DOWN: $userJson');

    final User user = User.fromJson(userJson);

    status = 'Deleted User';

    state = state.copyWith(
      id: user.id,
      name: user.name,
      description: user.description,
    );
  }

  Future<void> updateUser({
    required String id,
    required String name,
    String? description,
  }) async {
    final updateUserMutation =
        await rootBundle.loadString('lib/graphql/update_user.graphql');

    final MutationOptions options = MutationOptions(
      document: gql(updateUserMutation),
      variables: <String, dynamic>{
        // the variable put here must match the query variable ($patch)
        'patch': {
          'filter': {
            'id': [id],
          },
          'set': {
            'name': name,
            'description': description,
          }
        }
      },
    );

    final QueryResult result = await client.mutate(options);

    if (result.isLoading && result.data != null) {
      status = 'Loading';
    }

    if (result.hasException) {
      debugPrint(result.exception.toString());
      status = 'Error (See Logs)';
    }

    debugPrint('UPDATED USER: ${result.data}');

    final userJson = result.data?['updateUser']['user'][0];

    debugPrint('UPDATED USER TRIM DOWN: $userJson');

    final User user = User.fromJson(userJson);

    status = 'Updated User';

    state = state.copyWith(
      id: user.id,
      name: user.name,
      description: user.description,
    );
  }

  Future<void> getUser({required String id}) async {
    final getUserQuery =
        await rootBundle.loadString('lib/graphql/get_user.graphql');

    final QueryOptions options = QueryOptions(
      document: gql(getUserQuery),
      variables: <String, dynamic>{
        // the variable put here must match the query variable ($userID)
        'userID': id
      },
    );

    final QueryResult result = await client.query(options);

    if (result.isLoading && result.data != null) {
      status = 'Loading';
    }

    if (result.hasException) {
      debugPrint(result.exception.toString());
      status = 'Error (See Logs)';
    }

    debugPrint('GET USER: ${result.data}');

    final userJson = result.data?['getUser'];

    debugPrint('GET USER TRIM DOWN: $userJson');

    final User user = User.fromJson(userJson);

    status = 'Got User';

    state = state.copyWith(
      id: user.id,
      name: user.name,
      description: user.description,
    );
  }
}

For the @freezed annotation part, you declare the id, name and the nullable description(If you remember previously in your graphql schema, you had a nullable String type). Then added the fromJson to parse your json from the graphql requests.

## ignore lint analysis on generated files
analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"

If I explain the code from the @riverpod annotation by taking the "addUser" method as example. You loaded the GraphQL query file into a string. You create and intialize a MutationOptions variable, QueryOptions if getting the User. In the MutationOptions arguments. You create a GraphQL document by passing your string to ‘gql' special function. Then in your variables, you pass the json values to prepare for your request. In this case, that would be your name and description variable. When that part over, you can then call the request with client.mutate or client.query if getting the User passing along your options. Then you listen the results wether it is loading or having an exception. Finally you parse the json data with userJson and generate your User model that you will pass as your new state.

Now that you added your model, you can now link the widgets with Riverpod. Add the following in the files:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_sample/model/user.dart';

class UserFormField extends ConsumerStatefulWidget {
  final int index;
  final String name;
  const UserFormField({
    required this.index,
    required this.name,
    super.key,
  });

  @override
  UserFormFieldState createState() => UserFormFieldState();
}

class UserFormFieldState extends ConsumerState<UserFormField> {
  final idTextController = TextEditingController();
  final nameTextController = TextEditingController();
  final descriptionTextController = TextEditingController();

  InputDecoration namedOutlineInputBorder(String hint) {
    return InputDecoration(
      border: const OutlineInputBorder(),
      hintText: hint,
    );
  }

  @override
  void dispose() {
    idTextController.dispose();
    nameTextController.dispose();
    descriptionTextController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        children: [
          if (widget.index != 0)
            TextField(
              controller: idTextController,
              decoration: namedOutlineInputBorder('id (required)'),
            ),
          const SizedBox(height: 16),
          if (widget.index == 0 || widget.index == 3) ...[
            TextField(
              controller: nameTextController,
              decoration: namedOutlineInputBorder('name (required)'),
            ),
            const SizedBox(height: 16),
            TextField(
              controller: descriptionTextController,
              decoration: namedOutlineInputBorder('description (not required)'),
            ),
            const SizedBox(height: 16),
          ],
          OutlinedButton(
            onPressed: () async {
              FocusScope.of(context).unfocus(); // remove the keyboard
              final queries = ref.read(userQueriesProvider.notifier);

              switch (widget.name) {
                case 'ADD':
                  await queries.addUser(
                      name: nameTextController.text,
                      description: descriptionTextController.text);
                  break;
                case 'GET':
                  await queries.getUser(id: idTextController.text);
                  break;
                case 'DELETE':
                  await queries.deleteUser(id: idTextController.text);
                  break;
                case 'UPDATE':
                  await queries.updateUser(
                    id: idTextController.text,
                    name: nameTextController.text,
                    description: descriptionTextController.text,
                  );
                  break;
                default:
                  throw ('ERROR: widget name is ${widget.name}');
              }
            },
            child: Text(
              widget.name,
            ),
          )
        ],
      ),
    );
  }
}

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:graphql_sample/view/user_form_fields.dart';

import '../model/user.dart';

class HomeView extends StatelessWidget {
  const HomeView({super.key});

  @override
  Widget build(BuildContext context) {
    List<String> queryNames = ['ADD', 'GET', 'DELETE', 'UPDATE'];
    return DefaultTabController(
      length: 4,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('GraphQL Sample'),
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          bottom: TabBar(
            tabs: queryNames
                .map((name) => Tab(
                      text: name,
                    ))
                .toList(),
          ),
        ),
        body: SafeArea(
          child: SingleChildScrollView(
            child: Column(
              children: [
                SizedBox(
                  height: MediaQuery.of(context).size.height / 2,
                  child: TabBarView(
                    children: [
                      UserFormField(
                        index: 0,
                        name: queryNames[0],
                      ),
                      UserFormField(
                        index: 1,
                        name: queryNames[1],
                      ),
                      UserFormField(
                        index: 2,
                        name: queryNames[2],
                      ),
                      UserFormField(
                        index: 3,
                        name: queryNames[3],
                      ),
                    ],
                  ),
                ),
                const Divider(),
                const ResultWidget(),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class ResultWidget extends ConsumerWidget {
  const ResultWidget({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userQueriesProvider);
    final String? status = ref.read(userQueriesProvider.notifier).status;

    return Column(
      children: [
        Text(
          'Status: ${status ?? 'null'}',
          style: Theme.of(context).textTheme.titleLarge,
        ),
        Card(
          shape: const OutlineInputBorder(),
          elevation: 0,
          margin: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              ListTile(
                title: Text('Name: ${user.name}'),
                subtitle: Text('Id: ${user.id}'),
              ),
              Padding(
                padding: const EdgeInsets.all(16.0),
                child: Text(user.description ?? 'null'),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

The views are pretty straight forward but do take the time to understand them.

Now that your code is ready, you can build and run the app. Enter some values in the text fields. Make sure that you have added at least one User before doing get/update/delete queries. Even though you are capturing some errors with the status variable. I encourage you to check the debug logs in your console. It gives you a better idea about what type of errors you're encountering in the app.

If you have trouble running the app, you can compare your code from the github repository.

View Codelab Code

You started your own GraphQL server, built some queries along with linking the Flutter GraphQL client. You're on your way to your journey of being a full stack developer, now that you can handle both the backend and the frontend.

References

Some references that help me with the contents of this codelab.

Designing Graphql Schema from Dgraph

Reserved Names in Dgraph

GraphQL Variables for Clients

Get the Generated Schema in Dgraph

Update Mutation in Draph

Setup Localhost in Android

Feedback

This codelab may have some issues and to me this is only a draft, they are improvements that can be done. If you found a problem or have suggestions, you can click the report a mistake on the bottom left of the codelab it will open a github issue.