HasuraでGraphQL Server実装してFlutterのTODOアプリ作ってみる

個人的に最近GraphQLがきてるので(いまさら)、何かFlutterと絡めてやりたいなと思いやってみた。

こちらの記事を発見したので、少し手を加えながら作成。
めちゃくちゃ参考にしました、ありがとうございます！！
[Flutter + GraphQL with Hasura – The GeekyAnts Blog](https://blog.geekyants.com/flutter-graphql-with-hasura-d4d0b34621da)

（以下ToDoアプリのリポジトリ）
https://github.com/shinbey221/ToDo-App-with-Flutter

## HasuraとPrisma

両者はGraphQLを実装する際に採用されることが多いGraphQL Engineだがアーキテクトは異なる。

PrismaはあくまでGraphQL（またはREST）サーバー用のGraphQL ORMであり、クライアントから直接よべない。

一方Hasuraはクライアントから直接呼べるのでいろいろ実装が楽。
ただPostgreSQL専用のGraphQL Serverになる。
サクッと実装したい人向け。

## HasuraにGraphQL Server実装

#### Herokuにデプロイ

まず以下のURLを開く
[Instant realtime GraphQL on Postgres \| Hasura GraphQL Engine](https://hasura.io/)

Heroku Free Tierを選択

`Deploy to Heroku`を選択、この時アカウント作成も行われる
必要な情報を入力し進めるとデプロイが完了する
<img width="1440" alt="スクリーンショット 2019-07-03 15.43.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/ff5e9787-3dca-8656-9650-2087c1ea22c5.png">


#### Postgresのテーブルを作成、データを作る

下のViewボタンをクリックするとコンソールが開く
ヘッダーにDATAの項目を選択し、`create table`からテーブルを作成する
<img width="1440" alt="スクリーンショット 2019-07-05 13.49.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/2d283449-1ecf-03ae-e21e-f9e113fe8565.png">

デーブルが作成できたらヘッダーのGRAPHQLの項目に戻り、左メニューに作成したテーブルのqueryやmutationなどが選択できる。

insert_todoでmutaitonを発行しテーブルにデータを登録できる。
<img width="1440" alt="スクリーンショット 2019-07-03 15.52.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/aa6da596-baee-cfab-144e-ff2f382bd4ea.png">


## クライアント側のFlutterアプリ部分を作る

コンソール上でデータのCRUDが可能になり、GraphQL Serverの準備は完了。
実際にtodoアプリを作っていく。

#### 必要設定

まずは必要パッケージをインストールする。
`pubspec.yaml`に[graphql_flutter \| Flutter Package](https://pub.dev/packages/graphql_flutter)を追加

```yaml
dependencies:
  flutter:
    sdk: flutter
  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^0.1.2
  graphql_flutter: 1.0.0+4
```

`Package Get`でインストール


`main.dart`にgraphql_flutterをインポート

```dart
import 'package:graphql_flutter/graphql_flutter.dart';
```

`graphal_flutter`パッケージはアプリ全体をGraphQlProviderでラップする必要があるので以下のようにmainを修正する

```dart
void main() => runApp(
  GraphQLProvider(
    child: CacheProvider(
      child: MyApp()
    ),
  )
);
```

#### クライアントGraphQLの定義

クライアント側のGraphQLの定義をするファイルを作成する。
lib配下にservicesディレクトリを作成、配下に`graphQldata.dart`を作成する。

`graphQldata.dart`に必要パッケージのインポートとclass定義を行い、GraphQLServerのエンドポイントURLを設定、クライアント側の設定としてエンドポイントURLとキャッシュの設定を行い、clientのインスタンスを生成する

`graphQldata.dart`

```dart
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

class GraphQlObject {
  static HttpLink httpLink = HttpLink(
    uri: 'https://flutter-todo-app-hasura.herokuapp.com/v1/graphql',
  );
  static Link link = httpLink as Link;
  ValueNotifier<GraphQLClient> client = ValueNotifier(
    GraphQLClient(
      cache: InMemoryCache(),
      link: link,
    ),
  );
}

GraphQlObject graphQlObject = new GraphQlObject();
```

GraphQLServerに生成したテーブルの操作を行うためにmutation、queryを設定する。

`graphQldata.dart`

```dart
String updateCompletedMutation(result, index) {
  return (
      """mutation ToggleTask{
           update_todo(where: {
            id: {_eq: ${result.data["todo"][index]["id"]}}},
            _set: {isCompleted: ${!result.data["todo"][index]["isCompleted"]}}) {
               returning {isCompleted } 
           }
      }"""
  );
}

String deleteTaskMutation(result, index) {
  return (
      """mutation DeleteTask{       
            delete_todo(
               where: {id: {_eq: ${result.data["todo"][index]["id"]}}}
            ) { returning {id} }
      }"""
  );
}

String addTaskMutation(title, content) {
  print(title);
  print(content);
  return (
      """mutation AddTask{
            insert_todo(objects: {content: "$content", isCompleted: false, title: "$title"}) {
              returning {
                id
              }
            }
      }"""
  );
}

String fetchQuery() {
  return (
      """query TodoGet{
           todo {
              title
              content
              isCompleted
              id
           }
      } """
  );
}
```


#### GraphQLObjectの設定

GraphQLの設定ができたので、`main.dart`のGraphQLProviderにgraphQlObjectを設定する。

```dart
import 'package:todo_app_graphql/services/graphQldata.dart';  // 追加

void main() => runApp(
  GraphQLProvider(
    client: graphQlObject.client,  // 追加
    child: CacheProvider(
      child: MyApp()
    ),
  )
);
```

Mutationsでは、データベースを更新するためにGraphQLClientオブジェクトが必要なため、GraphQLClientオブジェクトを初期化する。

`main.dart`

```dart
class _MyHomePageState extends State<MyHomePage> {
  GraphQLClient client;
  // This widget is the root of your application.
  initMethod(context) {
    client = GraphQLProvider.of(context).value;
  }

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) => initMethod(context));
```

#### Queryの実行

ここから実際にQueryを実行して、受信したデータをリストで表示してみる。
Queryの受け取りはQuery Widgetを用いる。

```dart
return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Query(
          options: QueryOptions(document: fetchQuery(), pollInterval: 1),
          builder: (QueryResult result, {VoidCallback refetch}) {
          
          }
        )
      )
)
```


#### Queryを実行してデータを取得

optionで実装するQueryを指定し、builderで受けとったデータがresultとして返ってくる。
このresultを使ってデータをリスト表示してみる。

```dart
  
if (result.data == null) {
  return Center(
    child: CircularProgressIndicator(),
  );
}
return ListView.builder(
  itemCount: result.data["todo"].length,
  itemBuilder: (BuildContext context, int index) {
    return Card (
      child: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  height: MediaQuery.of(context).size.height/14.0,
                  padding: EdgeInsets.only(left: 15.0),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('Title',style: TextStyle(color: Colors.grey),),
                      Text(
                        result.data["todo"][index]["title"],
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 17.0),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  height: MediaQuery.of(context).size.height/14.0,
                  padding: EdgeInsets.only(left: 15.0),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('Content',style: TextStyle(color: Colors.grey),),
                      Text(
                        result.data["todo"][index]["content"],
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 15.0),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }
);

```

![Simulator Screen Shot - iPhone Xʀ - 2019-07-05 at 13.38.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/3bbf6f4c-c347-41ae-af92-adb06d6320fe.png)



#### Mutationで新しいデータの登録、更新、削除を行う

Queryを用いてDBのデータを一覧表示することができた。
次は新しいデータの生成、削除、更新をMutationを用いて行う。
まず新しいデータを生成できるように実装する。

入力した内容で新しいデータを生成する処理は以下のようになる

```dart
await client.mutate(
  MutationOptions(
    document: addTaskMutation(
        titleController.text, contentController.text),
  ),
);
```

フローティングボタンを押すとダイアログが立ち上がるように実装する。

```dart
floatingActionButton: FloatingActionButton(
  heroTag: "Tag",
  onPressed: () {
    showDialog(
        context: context,
        builder: (BuildContext context1) {
          return AlertDialog(
            shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(8.0))),
            title: Text("Add task"),
            content: Container(
              width: 500.0,
              child: Form(
                  child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: <Widget>[
                        TextField(
                          controller: titleController,
                          decoration: InputDecoration(labelText: "Title"),
                        ),
                        TextFormField(
                          maxLines: 10,
                          controller: contentController,
                          decoration: InputDecoration(labelText: "Coentent"),
                        ),
                        Center(
                            child: Padding(
                                padding: const EdgeInsets.only(top: 10.0),
                                child: RaisedButton(
                                    elevation: 7,
                                    shape: RoundedRectangleBorder(
                                      borderRadius: BorderRadius.circular(12),
                                    ),
                                    color: Colors.black,
                                    onPressed: () async {
                                      await client.mutate(
                                        MutationOptions(
                                          document: addTaskMutation(
                                              titleController.text, contentController.text),
                                        ),
                                      );
                                      Navigator.pop(context);
                                      titleController.text = "";
                                      contentController.text = "";
                                    },
                                    child: Text(
                                      "Add",
                                      style: TextStyle(color: Colors.white),
                                    )
                                )
                            )
                        )
                      ]
                  )
              ),
            )
          );
        }
     );
  },
  child: Icon(Icons.add),
),
```

これで内容を入力しAddボタンを押すとデータが生成され、一覧に反映される
![Simulator Screen Shot - iPhone Xʀ - 2019-07-05 at 13.40.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/e4f8ce09-7da4-f5c3-a9f8-350bea127a04.png)
![Simulator Screen Shot - iPhone Xʀ - 2019-07-05 at 13.40.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/442896/32927c6d-4e2b-f10f-6662-130380eb11ef.png)


次に削除と更新を実装する。
カードの要素にチェックボックスと削除アイコンを追加する。

```dart
final TextEditingController titleController = new TextEditingController();
final TextEditingController contentController = new TextEditingController();

return Card (
      child: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  height: MediaQuery.of(context).size.height/14.0,
                  padding: EdgeInsets.only(left: 15.0),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('Title',style: TextStyle(color: Colors.grey),),
                      Text(
                        result.data["todo"][index]["title"],
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 17.0),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
          Row(
            children: <Widget>[
              Flexible(
                child: Container(
                  height: MediaQuery.of(context).size.height/14.0,
                  padding: EdgeInsets.only(left: 15.0),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: <Widget>[
                      Text('Content',style: TextStyle(color: Colors.grey),),
                      Text(
                        result.data["todo"][index]["content"],
                        maxLines: 2,
                        overflow: TextOverflow.ellipsis,
                        style: TextStyle(fontSize: 15.0),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
          // 追加
          Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: <Widget>[
              Checkbox(
                value: result.data["todo"][index]["isCompleted"],
                onChanged: (bool value) {
                  
                 },
              ),
              IconButton(
                icon: Icon(Icons.delete),
                onPressed: () {
              
                },
              ),
            ],
          ),
        ],
      ),
    );
```

削除のonPressedを以下のようにする

```dart
onPressed: () async{
  await client.mutate(
    MutationOptions(
      document: deleteTaskMutation(
          result, index),
    ),
  );
},
```

チェックボックスのonChangedを以下のようにする

```dart
onChanged: () async {
  await client.mutate(
    MutationOptions(
      document: changeCompletedMutation(
          result, index),
    ),
  );
},
```

それぞれMutationを発行してデータの削除と更新を行なっている。
以上でToDoアプリの完成になる


# まとめ

今回HasuraでGraphQL Serverを実装してみたが、APIをコンソールで簡単に操作できるのは大きい。
PrismaはORM的な立ち位置のため環境構築に多少面倒臭さはあるがその分ガッツリカスタマイズできるので、しっかりサーバーサイドを実装したいのならPrismaの方がいいと思う。

FlutterもQuery,Mutationの定義さえしてしまえば他のところはそれほど難しいところはないので相性はそれなりにいいと感じた。