Flutterを使って１ソース・マルチユースでMovable Typeクライアントを作れるか？

# この記事は？

この記事は [Movable Type Advent Calendar 2020](https://adventar.org/calendars/4971) 3日目の記事です。

今年はちょっとバタバタしていて「ああ、エントリーできないなぁ」なんて考えていたのですが、えいやっと登録してみました。さてネタがないぞ、、、

ネタが無い場合は今やっている仕事から持ってくれば良いというのが通例（？）なので、最近やっている仕事の事をお話ししつつ、あとはどこまで出来るかという時間との勝負といった感じで進めて行きたいと思います（毎年だなｗ）。

# 簡単に自己紹介

- 元Six Apart社員
- TypePad（現Lekumo）チームで開発 ⇒ Movable Typeチームでドキュメント（プラグイン開発ガイド、MTMLガイド、等） ⇒ 退社してからはMTプラグイン構築 ⇒ 現在はMTからは遠ざかってます
- 今はPHP(Laravel), node.js(Vue.js)を中心にFlutterやらKotlinやら雑食系エンジニアやってます
- 去年に引き続きAdvent Calendarに参加

# 今やってること、Flutterについて

仕事は今、アプリの開発をFlutterを使ってやっています。FlutterはGoogleが開発している１ソース・マルチユースの考えから作られたプラットフォームで、Dart言語で１つのソースコードを書くと標準でiOSとAndroidの両対応できるというのが売りです。パッケージと呼ばれるモジュールさえあれば、iOSとAndroidの違いを気にすることなくコーディング・テスト・リリースができます（実際は違いによってドツボにハマることもあるので、この辺りは話半分くらいで聞いていてください）。
ではパッケージが無い場合はどうするかというと、自前で作るしかありません。作るときは結局SwiftとKotlinの知識が必要になってきます。自分はそこまで必要になったことがないのでやった事はありませんが、将来的には必要になってくるのだろうなと思っています。

# Flutterの今後

上では「iOSとAndroid用」といった感じの事を書いていますが、将来メインストリームにマージされるであろう流れとして以下のような物があります

- Flutter for Web
 - Dartで書いたコードをWebページ化してブラウザで表示できる
 - 現在Beta版まで、そろそろ使えそう
- Flutter for XXX
 - Dartで書いたコードをデスクトップアプリ化して、macOS、Windows、Linux、Fuchsiaで起動できる
 - 現在Alpha版まで

ただし１ソース・マルチユースと言っても、パッケージに依存するためPure Dartで書かれたパッケージ以外を使うとFlutter for Webでは利用出来ないなどの制限や、現時点でAlpha版やBeta版という事もあり、業務で利用出来る状況にはありません。今回はデモなのでAlpha版（dev channel）を利用し、上記全てに対応できる準備をした上で進めます。

# どんな物を作るか？（12/03 15:00追加、西山さんありがとうございます）

Flutterを使ってMTのクライアントアプリの原型を作ってみたいと思います。Flutterで作ったクライアントアプリにDataAPIをつなげ、ブログの情報（ブログ名）を取得するところまでやってみます。これを突き詰めていけば、FlutterでMTのクライアントアプリが作れ、アプリからブログの更新ができるという訳ですね。

# 開発環境

- MacBook Pro 2015 : macOS Big Sur 11.0.1
- Android Studio 4.1.1

## OS

去年はCatalinaからMojaveに戻したなんて書いてますが、人柱的にBig Surにしてます。M1 mac欲しい。

## IDE

Flutterの開発は基本的にIDEを使います。Android StudioでもVScodeでも開発はできますが、IntelliJ IDEA系IDEを愛す私はAndroid Studioを使います。

# セットアップ

基本的なセットアップは [公式サイト](https://flutter.dev/docs/get-started/install) が詳しいので、そちらをご覧ください。
今回はdev channelを使うのでセットアップ後に以下を実行します。

```shell
$ flutter channel dev
$ flutter upgrade
$ flutter config --enable-web
$ flutter config --enable-macos-desktop
$ flutter config --enable-windows-desktop
$ flutter config --enable-linux-desktop
```

これで準備OK、次にFlutterのプロジェクトを作成します。

```shell
$ mkdir ${WORK_DIR}/mt_test_app
$ cd ${WORK_DIR}/mt_test_app
$ flutter create .
```

macOS上でアプリをテスト起動してみます。普段から、この辺りまではIDE使わずにコマンドラインでやってます。

```shell
$ flutter run -d macos
```

![スクリーンショット 2020-11-27 12.40.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/1a7d6773-a448-38dd-35fd-12e92c150dcb.png)

起動できました。右下のフローティングボタンをクリックすると、真ん中の数字がカウントアップされます。いい感じです。

試しにiOS版を起動してみます。

```shell
$ flutter run -d XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXX
```

![スクリーンショット 2020-11-27 12.51.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/a28fc9b0-674a-f5ae-2b01-20eaa901efa2.png)

macOS版と同じ画面がでました。

同じく、Flutter for Webの画面を見てみましょう。

```shell
$ flutter run -d chrome
```

![スクリーンショット 2020-11-27 13.13.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/4c915485-77f2-f116-9f2c-689b0dd5676d.png)

Chrome上で同じアプリが起動しました！！

# 開発

さて、開発するのにMovable TypeのData APIが必要です。今回は対向としてMovableType.netのData APIを使ってみます。
[公式サイトの記述](https://movabletype.net/support/setting/data-api.html) に沿ってData APIが利用出来る状態にしておきます。

APIアクセスをするのに [httpパッケージ](https://pub.dev/packages/http)を使うのでpubspec.yamlのdependenciesに追加して以下のコマンドを打ちます（IDEの`Pub get`リンクをクリックしてもOK）。

```shell
$ flutter pub get
```

## APIのバージョンを取得するだけのアプリ

これで準備が整ったので、APIのバージョンを取得するだけのアプリを作ってみたいと思います。

ここからは３分クッキング。以下のソースコードになります。

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.green,
      ),
      home: DataApiTestWidget(),
    );
  }
}

class DataApiTestWidget extends StatefulWidget {
  @override
  _DataApiTestWidgetState createState() => _DataApiTestWidgetState();
}

class _DataApiTestWidgetState extends State<DataApiTestWidget> {
  final TextEditingController _textEditingController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initFuture(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          _textEditingController.text = snapshot.data;
        } else {
          _textEditingController.text = 'データが取得出来ませんでした。';
        }
        return Scaffold(
          appBar: AppBar(
            title: const Text('Data API Test'),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextFormField(
              controller: _textEditingController,
              enabled: false,
              maxLines: 999999,
              decoration: InputDecoration(
                hintStyle: TextStyle(fontSize: 16),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(16),
                  borderSide: BorderSide(
                    width: 0,
                    style: BorderStyle.none,
                  ),
                ),
                filled: true,
                contentPadding: EdgeInsets.all(16),
                fillColor: Colors.grey,
              ),
            ),
          ),
        );
      }
    );
  }

  Future<String> _initFuture() async {
    final response = await http.get('https://movabletype.net/.data-api/version');
    return Future.value(response.body);
  }
}
```

内容を細かく説明すると尺が収まらないので、最後の`_initFuture`の部分だけ。MovableType.netのAPI URLのうち、バージョン番号を取得するAPI URL [https://movabletype.net/.data-api/version](https://movabletype.net/.data-api/version) を叩いて、その結果を返しているだけです。その内容がNULLでなければ内容を表示し、NULLなら「データが取得出来ませんでした。」と表示します。

実際に実行してみます。

![スクリーンショット 2020-11-27 14.04.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/2b633b2f-0c03-1dc3-3818-ca0c378178e4.png)

iOSでは取得出来ました。念のためAndroidでも試してみます。

![スクリーンショット 2020-11-27 14.49.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/021563af-d29d-55fa-59bf-b37db5f036b1.png)

無事取得出来ました。では、macOSとWebも。

![スクリーンショット 2020-11-27 14.43.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/882995c9-8e0a-aef1-0148-fb5f7511f324.png)

![スクリーンショット 2020-11-27 14.42.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/d1008151-848a-c006-99b4-9b921db58571.png)

あれ？データが取得出来ませんね。この理由は、macOS Appは標準でサンドボックスの中で動作しているので外部へのネットワーク接続が出来ません。Web版はCORSのためそのままでは外部へのネットワーク接続が出来ません。以下のようにします。

【macOSはこちら】

```xml:/macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
	<!-- 以下を追加 -->
	<key>com.apple.security.network.client</key>
	<true/>
</dict>
</plist>
```

【Webはこちら】

```shell
$ open -n /Applications/Google\ Chrome.app --args --disable-web-security --user-data-dir="/tmp"
```

macOSは外部ネットワーク接続をONにしてやり、Web版はセキュリティを落としています。Web版は本来ならオリジンなどを設定するべきですが簡易チェックのためこのようにしています。そのままの設定で外部サイトに接続しないように注意してください。

![スクリーンショット 2020-11-27 14.57.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/711db5bc-d9a1-6f38-12fe-6120fe2987bd.png)

![スクリーンショット 2020-11-27 15.00.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/697154cb-a128-de6b-bd0e-651639679bda.png)

macOSもWebも無事APIの結果を取得出来ました。


## サイトの名前を取得するアプリ

APIへのアクセスが出来るようになったので、認証を行った上でサイトの名前を取得するアプリを作ってみたいと思います。簡単にするために数あるサイトの最初の一つの名前を取得してみます。

main.dartを以下のように書き換えます。host, username, passwordは適宜書き換えてください。

```dart:main.dart
import 'dart:convert';

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.green,
      ),
      home: DataApiTestWidget(),
    );
  }
}

class DataApiTestWidget extends StatefulWidget {
  final host = 'your-subdomain.movabletype.io';
  final username = 'your_name';
  final password = 'your_password';
  final clientId = 'foobar';

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

class _DataApiTestWidgetState extends State<DataApiTestWidget> {
  final TextEditingController _textEditingController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _initFuture(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          _textEditingController.text = snapshot.data;
        } else {
          _textEditingController.text = 'データが取得出来ませんでした。';
        }
        return Scaffold(
          appBar: AppBar(
            title: const Text('Data API Test'),
          ),
          body: Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextFormField(
              controller: _textEditingController,
              enabled: false,
              maxLines: 999999,
              decoration: InputDecoration(
                hintStyle: TextStyle(fontSize: 16),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(16),
                  borderSide: BorderSide(
                    width: 0,
                    style: BorderStyle.none,
                  ),
                ),
                filled: true,
                contentPadding: EdgeInsets.all(16),
                fillColor: Colors.grey,
              ),
            ),
          ),
        );
      },
    );
  }

  Future<String> _initFuture() async {
    final accessToken = await _authentication();
    final siteName = await _getFirstSiteName(accessToken);
    return Future.value(siteName);
  }

  Future<String> _authentication() async {
    final url = 'https://' + widget.host + '/.data-api/v4/authentication';
    final Map<String, dynamic> body = Map();
    body['username'] = widget.username;
    body['password'] = widget.password;
    body['clientId'] = widget.clientId;
    final response = await http.post(url, body: body);
    if (response.statusCode == 200) {
      final auth = Authentication.fromJson(json.decode(response.body));
      return Future.value(auth.accessToken);
    } else {
      return Future.value(null);
    }
  }

  Future<String> _getFirstSiteName(String accessToken) async {
    final url = 'https://' + widget.host + '/.data-api/v4/sites';
    final response = await http.get(url, headers: {
      'X-MT-Authorization': 'MTAuth accessToken=' + accessToken,
    });
    if (response.statusCode == 200) {
      final responseJson = json.decode(response.body);
      return Future.value(responseJson['items'].first['name']);
    } else {
      return Future.value('取得に失敗しました');
    }
  }
}

class Authentication {
  String accessToken;
  String sessionId;
  int expiresIn;
  bool remember;

  Authentication({
    this.accessToken,
    this.sessionId,
    this.expiresIn,
    this.remember,
  });

  Authentication.fromJson(Map<String, dynamic> json)
      : accessToken = json['accessToken'] as String,
        sessionId = json['sessionId'] as String,
        expiresIn = json['expiresIn'] as int,
        remember = json['remember'] as bool;

  Map<String, dynamic> toJson() => {
    'accessToken': accessToken,
    'sessionId': sessionId,
    'expiresIn': expiresIn,
    'remember': remember,
  };
}
```

1. `_authentication()`の中で認証を行います。そのレスポンスをDartオブジェクトに変換し、その中の`accessToken`を返します
2. そのaccessTokenを使って`_getFirstSiteName()`でサイトの名前を取得しています。その際、ヘッダーに`X-MT-Authorization: MTAuth accessToken=xxxxx`といった形でaccessTokenを渡すのがミソです。

![スクリーンショット 2020-11-27 17.12.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/65508/9d1ed729-441a-f3f5-e6a9-2ea5ae6a03d9.png)

サイトの名前が取得出来ました。サイトの情報もDartオブジェクトに変換出来ますが、少しソースコードのボリュームが多くなるので省略させて頂きました。サイト名だけなら公開済みサイトであればオープンな情報なので認証通さなくても取得できましたね（自分でツッコミいれておきます）。まぁ、認証の通し方のおさらいと思ってくださいｗ

# 今後という名のまとめ

ここまで書いておいて唐突にまとめです。毎回ですがやりたいことと、やれることのボリュームに乖離があって走りきることができません（私の悪い癖）。ただ表題の「Flutterを使って１ソース・マルチユースでMovable Typeクライアントを作れるか？」については答えがでていて「YES」ですね。
実際にAPIコールする所までしか今回はやっていませんが、当たり前のようにFlutterだけでMovable Typeクライアントが作成可能です。その当たり前を実際に手順を追って１ソース・マルチユースで作っていけたのはFlutterというプラットフォームのおかげ以外の何者でもありません。
今後は今回の投稿をきっかけに「実際にMovable TypeクライアントをFlutterで作成する」所までやってみたいなと考えています。あとは時間が沸いて出てくれば、ですが（苦笑）