Flutter 導入から kintone REST API 実行まで

# はじめに
以前触った Flutter について導入から kintone にリクエストを送信するアプリの作成までアウトプットしてみます。
普段 JavaScript しか触らないので、おかしなコードがあると思いますが、ご指摘いただけるとありがたいです。

# Flutter 導入
## インストール
公式サイトの Get Started に書いてある通り、OS 選択して導入します。
https://flutter.dev/docs/get-started/install
ドキュメントがしっかりしているので、ちゃんと読めば詰まることはないはずです。
私は英語で躓きました orz

## パス追加
macOS に書かれている内容で１点だけ注意が必要なのは、後述のコマンドだと、
一時的なものなので、ターミナル再起動すると毎回パスを通す作業が必要になります。
なので、しっかりと元から変更してあげましょう。
私はまだ zsh に移行していないので今回は bash_profile に Path を追加しました。

```bash
$ export PATH="$PATH:`pwd`/flutter/bin"
```

## 周辺設定
- flutter doctor 実行  
コマンドが実行可能になったら以下のコマンドを実行します。  
環境によって出力されるログが違いますが、  
ログに書いてある通り、ツールのインストールとコマンドの実行を行いましょう。

```bash:実行例
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.12.13+hotfix.5, on Mac OS X 10.15.1 19B88, locale ja-JP)
[✗] Android toolchain - develop for Android devices
    ✗ Unable to locate Android SDK.
      Install Android Studio from: https://developer.android.com/studio/index.html
      On first launch it will assist you in installing the Android SDK components.
      (or visit https://flutter.dev/setup/#android-setup for detailed instructions).
      If the Android SDK has been installed to a custom location, set ANDROID_HOME to that location.
      You may also want to add it to your PATH environment variable.

[✗] Xcode - develop for iOS and macOS
    ✗ Xcode installation is incomplete; a full installation is necessary for iOS development.
      Download at: https://developer.apple.com/xcode/download/
      Or install Xcode via the App Store.
      Once installed, run:
        sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
        sudo xcodebuild -runFirstLaunch
    ✗ CocoaPods not installed.
        CocoaPods is used to retrieve the iOS and macOS platform side's plugin code that responds to
        your plugin usage on the Dart side.
        Without CocoaPods, plugins will not work on iOS or macOS.
        For more info, see https://flutter.dev/platform-plugins
      To install:
        sudo gem install cocoapods
[!] Android Studio (not installed)
[!] VS Code (version 1.40.2)
    ✗ Flutter extension not installed; install from
      https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter
[!] Connected device
    ! No devices available

! Doctor found issues in 5 categories.
```
× がついている箇所について、必要なツールをインストールして解決します。

1. Android Studio インストール
2. Android Studio セットアップ
3. XCode インストール
4. XCode セットアップ

を行えば大体解決するはずです。各ツールのインストールは省略します。

**2. Android Studio セットアップ**
Preferences > Plugins で Dart と Flutter をインストールします。

**4. XCode セットアップ**
以下のコマンドを実行します。

```bash
$ sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
$ sudo xcodebuild -runFirstLaunch
$ sudo gem install cocoapods
```

- VS Code 拡張（必要な人のみ）  
VS Code でコーディングを行う人はこの拡張をインストールして置くと便利っぽいです。

https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter

セットアップまで完了したら、再度 doctor を実行します。

```bash
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.12.13+hotfix.5, on Mac OS X 10.15.1 19B88, locale ja-JP)
 
[✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2)
[✓] Xcode - develop for iOS and macOS (Xcode 10.2.1)
[✓] Android Studio (version 3.5)
[✓] VS Code (version 1.41.0)
[!] Connected device
    ! No devices available

! Doctor found issues in 1 category.
```
× が消えていたら完了です。
万が一 × が記載されていてもエラー文中に解決方法が記載されているので、
１つずつ解決していけば問題ないはずです。

「！」は　Waring なので積極的に解決する必要はないです。

ここまで完了すれば開発環境は OK です。

## サンプルアプリ作成
以下のコマンド実行で、サンプルアプリを作成できます。

```bash
# シミュレーター起動
$ open -a Simulator
# flutter プロジェクト作成
$ flutter create my_app
# シミュレーターで実行
$ cd my_app
$ flutter run
```

run まで実行すると以下のアプリがシミュレーター上で起動されます。

<img width="535" alt="スクリーンショット 2019-12-16 16.51.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214670/23594c32-10da-801c-d10c-aa70760f6bdf.png">

ここまでで導入完了です。

Flutter はシミュレーター上でアプリを起動しながら開発が可能です。
run 中に、ターミナル上で "r" を入力するとホットリロード、 "R" を入力するとホットリスタートができます。

実際に先ほど作成した my_app 内の main.dart 23行目を適当に変更してターミナル上で "r" を入力すると、
シミュレーターがホットリロードされ、中央に表示される文字が変更されていることを確認できます。

完成された画面をみながら開発できるので、開発効率は高そうです。

# kintone ネイティブアプリ開発

ここからは、先ほど作成した my_app を変更して、実際に kintone API を実行するアプリを作成します。
kintone の開発環境は以下から取得できます。
[開発者ライセンスのお申込み](https://developer.cybozu.io/hc/ja/articles/200720464#step4)

## kintone アプリ作成

kintone 自体に Flutter 連携用のアプリを作成します。
今回はアプリストアの「案件管理」アプリを少し修正して使用しました。
以下の3フィールドのみ、フィールドコードを修正しています。

会社名 companyName
TEL tel
製品名 product

また、API Token をアクセス権「レコード閲覧」「レコード追加」にチェックを入れて作成します。

## HTTP リクエスト用のパッケージ導入

my_app 直下に存在する pubspec.yaml の dependencies について記載されている部分に
http: ^0.12.0 を追加します。
http モジュールの最新版は↓を参照。
https://pub.dev/packages/http

```yaml:pubspec.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
  http: ^0.12.0
```

変更したら保存してパッケージ取得のためのコマンドを実行します。
VSCode で Dart の拡張を入れていると pubspec.yaml 保存時に自動で実行されます。
https://dartcode.org/docs/settings/#dartrunpubgetonpubspecchanges

```bash
$ flutter pub get
```

これでリクエストするための準備は完了です。

## リクエストのための関数作成
まずは main.dart に http と convert というパッケージを import します。
convert パッケージは初期から入っているので、そのまま使用できます。

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
```

次に実際に API を実行する関数と必要な変数を宣言していきます。
dart では JavaScript と同様に、変数宣言に const, var が使用できます。
※独特の性質があるようですが割愛します。
`const 型 変数名` という風に記述します。
関数を宣言する際は以下のように記述できます。
`戻り値の型 関数名(パラメータ) {}`
https://dart.dev/guides/language/language-tour#class-variables-and-methods

```dart
// 環境情報の設定
const String kintoneDomain = '{subdomain}.cybozu.com';
const String url = 'https://$kintoneDomain/k/v1';
const String apiToken = '{API Token}';
const String appId = '{APP ID}';
const Map<String, String> getHeader = {
  // Api token Auth
  'X-Cybozu-API-Token': apiToken,
};
const Map<String, String> postHeader = {
  // Api token Auth
  'X-Cybozu-API-Token': apiToken,
  'Content-Type': 'application/json'
};

// fetch records from Kintone
Future<Map<String, dynamic>> fetchRecords(String query) async {
   String encodedQuery = Uri.encodeFull(query);
   return await http.get(
    '$url/records.json?app=$appId&query=$encodedQuery',
    headers: getHeader
  ).then(((response) {
    print('Response status: ${response.statusCode}');
    print('Response body: ${response.body}');
    // Json response decode
    Map<String, dynamic> rec = jsonDecode(response.body);
    return rec;
  }));
}

// post record to Kintone
Future<Map<String, dynamic>> postRecord(String company, String tel, String product) async {
  Map<String, dynamic> postRecord = {
    'app': appId,
    'record': {
      'companyName': {
        'value': company
      },
      'tel': {
        'value': tel
      },
      'product': {
        'value': product
      }
    }
  };
  return await http.post(
    '$url/record.json',
    headers: postHeader,
    body: jsonEncode(postRecord)
  ).then(((response) {
    print('Response status: ${response.statusCode}');
    print('Response body: ${response.body}');
    // Json response decode
    Map<String, dynamic> rec = jsonDecode(response.body);
    return rec;
  }));
}
```
ここで作成した fetchRecords と postRecord 関数を実際に動作してみます。
簡単に確認するために、MyApp クラスの build メソッド内に以下のコードを追加します。

```dart
class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    fetchRecords('').then((resp) {print(resp)});
    postRecord('from flutter', '0-0-0', 'kintone');
    ...
```
追加したら、ターミナル上で「r」を入力してホットリロードするとログが出力され、
kintone REST API の実行が確認できます。


## モックアプリ作成
先ほどの API 実行関数を使ってレコードの一覧ビューと追加画面のモックを作成します。

コードは長いので折りたたんであります↓
<details><summary>main.dart サンプルコード</summary><div>

```dart:main.dart

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

void main() => runApp(MyApp());

const String kintoneDomain = '{subdomain}.cybozu.com';
const String url = 'https://$kintoneDomain/k/v1';
const String apiToken = '{API Token}';
const String appId = '{APP ID}';
const Map<String, String> getHeader = {
  // Api token Auth
  'X-Cybozu-API-Token': apiToken,
};
const Map<String, String> postHeader = {
  // Api token Auth
  'X-Cybozu-API-Token': apiToken,
  'Content-Type': 'application/json'
};

// fetch records from Kintone
Future<Map<String, dynamic>> fetchRecords(String query) async {
   String encodedQuery = Uri.encodeFull(query);
   return await http.get(
    '$url/records.json?app=$appId&query=$encodedQuery',
    headers: getHeader
  ).then(((response) {
    print('Response status: ${response.statusCode}');
    print('Response body: ${response.body}');
    // Json response decode
    Map<String, dynamic> rec = jsonDecode(response.body);
    return rec;
  }));
}

// post record to Kintone
Future<Map<String, dynamic>> postRecord(String company, String tel, String product) async {
  Map<String, dynamic> postRecord = {
    'app': appId,
    'record': {
      'companyName': {
        'value': company
      },
      'tel': {
        'value': tel
      },
      'product': {
        'value': product
      }
    }
  };
  return await http.post(
    '$url/record.json',
    headers: postHeader,
    body: jsonEncode(postRecord)
  ).then(((response) {
    print('Response status: ${response.statusCode}');
    print('Response body: ${response.body}');
    // Json response decode
    Map<String, dynamic> rec = jsonDecode(response.body);
    return rec;
  }));
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MainView()
    );
  }
}

class MainView extends StatefulWidget {
  @override
  _MainViewState createState() => _MainViewState();
}

class _MainViewState extends State<MainView> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('レコード一覧'),
        actions: <Widget>[
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              setState((){});
            },
          )
        ],
      ),
      body: Center(
        child: RecordsList(),
      ),
      floatingActionButton: Button(),
    );
  }
}

class RecordsList extends StatefulWidget {
  @override
  _RecordsListState createState() => _RecordsListState();
}

class _RecordsListState extends State<RecordsList> {

  @override
  Widget build(BuildContext context) {
    return _futurelist();
  }

  Widget _futurelist() {
    return FutureBuilder(
      future: fetchRecords(''),
      builder: (BuildContext context, AsyncSnapshot<Map<String, Object>> records) {
        if (records.hasData && records.data['code'] == null) {
          return _listView(records.data);
        }
        else if (records.hasData && records.data['code'] != null) {
          return Text('レコード取得に失敗しました。');
        } else {
          return CircularProgressIndicator();
        }
      },
    );
  }

  Widget _listView(Map<String, dynamic> resp) {
    return ListView.builder(
      padding: const EdgeInsets.all(16.0),
      itemBuilder: (context, i) {
        if (i.isOdd) return Divider();
        final index = i ~/ 2;
        return _buildRow(resp['records'][index]);
      },
      itemCount: resp['records'].length * 2,
    );
  }

  Widget _buildRow(dynamic record) {
    return ListTile(
      title: Text(record['companyName']['value']),
    );
  }
}

class Button extends StatefulWidget {
  @override
  _ButtonState createState() => _ButtonState();
}

class _ButtonState extends State<Button> {
  String company = '';
  String tel = '';
  String product = 'kintone';

  @override
  Widget build(BuildContext context) {
    return _button();
  }

  Widget _button() {
    return FloatingActionButton(
      onPressed: () {
        company = '';
        tel = '';
        product = 'kintone';
        _showDialog();
      },
      child: const Icon(Icons.add)
    );
  }

  Future<Null> _showDialog() async {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          content: Form(
            child: Column(
              children: _formColumn()
            ),
          ),
        );
      },
    );
  }

  List<Widget> _formColumn() {
    return <Widget>[
      Padding(
        padding: EdgeInsets.all(8.0),
        child: Text('会社名')
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: TextFormField(
          initialValue: company,
          onChanged: (String value) {
            company = value;
          },
        )
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: Text('TEL')
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: TextFormField(
          initialValue: tel,
          onChanged: (String value) {
            tel = value;
          },
        )
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: Text('製品')
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: DropdownButton<String>(
          value: product,
          icon: Icon(Icons.arrow_downward),
          iconSize: 24,
          elevation: 16,
          style: TextStyle(
            color: Colors.blue
          ),
          onChanged: (String newValue) {
            setState(() {
              product = newValue;
              Navigator.of(context).pop();
              _showDialog();
              // Navigator.of(context).pop();
              // _postDialog();
            });
          },
          items: <String>['kintone', 'Garoon', 'Office']
            .map<DropdownMenuItem<String>>((String value) {
              return DropdownMenuItem<String>(
                value: value,
                child: Text(value),
              );
            }).toList(),
        )
      ),
      Padding(
        padding: EdgeInsets.all(8.0),
        child: RaisedButton(
          child: Text('登録'),
          color: Colors.blue,
          onPressed: () {
            postRecord(company, tel, product).then((resp) {
              Navigator.of(context).pop();
            });
          },
        ),
      )
    ];
  }

}
```

</div></details>

## 動作確認
![sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214670/7acb7ff5-56b2-2dc0-d23d-acd521c7c0a3.gif)

できました。

# 参考サイトまとめ
## 1. 最初に見るべきサイト
- [Get started](https://flutter.dev/docs/get-started/install)
- [A tour of the Dart language](https://dart.dev/guides/language/language-tour)  

Flutter は公式ドキュメントがとても丁寧なので困ったら公式サイトを見ると解決できます。
私は、まずは Get Started でアプリ作成し、次に言語的な学習を2つ目のサイトで行いました。
特に2つ目はかなり基本的な部分が書かれているので、ブックマークしてコーディング中も見返していました。

## 2. Statelesswidget と Statefullwidget
２つ目の学習ポイントとして上げられるかと思います。
Widget と state という概念は頻出するので、抑えたほうが良さそうですね。

参考にしたサイトは以下の通りです。

- [Flutter: Stateful vs Stateless Widget](https://medium.com/flutter-community/flutter-stateful-vs-stateless-db325309deae)
- [Flutterの基礎](https://qiita.com/nkmk1215/items/7d73b4fd8cb5ec5ac486)
- [Flutterの静的ページを勉強してなんとなくまとめてみた](https://qiita.com/tasogarei/items/b86f34fe710cf163b3eb)  

## 3. JSON のエンコードとデコード
REST API では良く JSON を扱うので一読しておくと良いと思います。  

- [JSON and serialization](https://flutter.dev/docs/development/data-and-backend/json#serializing-json-manually-using-dartconvert)

## 4. Widget について
dart でデフォルトで扱える widget について記載されています。

- [Widget catalog](https://flutter.dev/docs/development/ui/widgets)

## 5. HTTP リクエストについて
HTTP リクエストに使用するパッケージの使用方法について学習する必要があったので、以下のサイトを参考にしました。

- [Fetch data from the internet](https://flutter.dev/docs/cookbook/networking/fetch-data)
- [How to make HTTP requests in Flutter](https://medium.com/swlh/how-to-make-http-requests-in-flutter-d12e98ee1cef)

## 6. その他
私が今回一番ハマった実装は「アラートダイアログ内にドロップダウンを描画する」です。
めちゃくちゃハマりました。解決のために読んだサイトです。

- [DropDownList inside Dialog](https://github.com/flutter/flutter/issues/19343)
- [Problem using Slider on AlertDialog](https://github.com/flutter/flutter/issues/19323)
- [Flutter - Why slider doesn't update in AlertDialog?](https://stackoverflow.com/questions/51271061/flutter-why-slider-doesnt-update-in-alertdialog)

# 改善
- State についてまだ理解が甘く、Stateless, Stateful の使い分けができていない  
ここらへん読みます。  
[Stateful Widget のパフォーマンスを考慮した正しい扱い方](https://medium.com/flutter-jp/state-performance-7a5f67d62edd)
- エラーハンドリングについて  
Future widget のエラーハンドリング書いていないので追加する必要がありそうですね。。
- レコード詳細画面作成  
ページ遷移について学習できなかったので、タップして画面遷移する処理を書いていません。学習します。