FlutterでMisskeyのクローンアプリを作りたかった（作ったとは言ってない）

## はじめに

この記事は[Flutter #2 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/flutter-2)の１３日目の記事です。

遅くなりまして、すみません。

先に言っておきますが、我こそはFlutterの伝道師や！と思っていらっしゃる方は読まなくても困りませんし、
おそらく不快指数をあげる事もないでしょう。

~~以降私の生産したガタガタの車輪の紹介になります~~

## misskey

皆さんはMisskeyってご存知ですか？

![](https://i.gyazo.com/7f44c2a28a5865a3d35240fdba5c808f.png)

> Misskeyは、地球で生まれた分散マイクロブログSNSです。Fediverse(様々なSNSで構成される宇宙)の中に存在するため、他のSNSと相互に繋がっています。
> [出典： https://joinmisskey.github.io/ja/](https://joinmisskey.github.io/ja/)

ご存知ない方は[syuio](https://github.com/syuilo)さん作の分散型Twitter([マストドン](https://mstdn.jp/)的な)と思って頂ければ近いかなと。

Twitterやマストドンよりも、かなり自由度の高いSNSです。

私があまり何か言っても野暮なので、一度触ってもらえればと思いますが、
個人的に開発者の方の遊び心が随所に見えて、触ってて楽しくて好きです。

~~藍ちゃんかわいい。~~

## 本題

今年の夏、Flutterが熱かったので、
勉強がてら、Misskeyのクローンアプリを作ってみようと思いました。

アプリ名は**Miss**keyとの言葉遊びで**Mr**keyとしてみました。

APIの公式ドキュメントが充実してたので頑張れば作れると思ったのですが、
**私の元気とやる気が持続しなかった**ため、中途半端な状態で放置したまま
ガワ（それも一部）しか作れていません。

ほんとはちゃんとしたのを作ってドヤりたかったのですが、できませんでした。
僕と皆さんの反面教師にしていただくべく、あえて晒します。

> 実機はGalaxy S9です。そのためスクリーンショットの一部にEDGEが写り込んでますが気にしないでください。

### とりあえず起動画面

![Screenshot_20181213-232621.jpg](https://qiita-image-store.s3.amazonaws.com/0/129315/0365e10b-148b-e1c0-afdc-c6ea2d3caffa.jpeg)

AppBarにハンバーガーメニューボタン、タイムラインのドロップダウン、投稿ボタン、ダークテーマ切り替えボタン（後述）を並べてみました。
ただ、今思うと、投稿ボタンは右下に配置した方が使い勝手良いかな、と思います。

こんな感じで並べてます。ハンバーガーメニューは後述のdrawerを指定することで勝手に出ます。

```dart
    final _appBarItems = <Widget>[
      Padding( // タイムライン
        padding: EdgeInsets.all(_appBarPadding),
        child: Theme(
            data: _currentTheme.copyWith(
              canvasColor: _primaryColor,
            ),
            child: _timelineButton
        ),
      ),
      Padding( // 投稿ボタン ガワだけなので機能なし
        padding: EdgeInsets.all(_appBarPadding),
        child: IconButton(
          icon: Icon(Icons.create),
          onPressed: (){},
        ),
      ),
      Padding( // テーマ切り替えボタン
        padding: EdgeInsets.all(_appBarPadding),
        child: IconButton(
          icon: Icon(Icons.brightness_3),
          onPressed: () {
            setState(_toggleTheme);
          },
        ),
      ),
    ];
```

Misskeyにはテーマ選択機能があります。
夏にいじってた当初はライトテーマとダークテーマの2種しかなかったと思っていましたが、
この記事を書くにあたり久々にログインしたところライトテーマ、ダークテーマそれぞれに複数の選択肢がありました。
ほんとすごいなMisskey(の開発者)

### ダークテーマ切り替えボタン

ダークテーマ切り替えボタンを押すとこんな感じになります。

![Screenshot_20181213-232628.jpg](https://qiita-image-store.s3.amazonaws.com/0/129315/2e5d75dd-7c2d-95b4-6464-818d9081d6d4.jpeg)

見て分かるように背景色が黒に変わるだけなんですが、地味に苦労しました。

というのもFlutterのMaterialパッケージに用意されている`ThemeData`クラスで
`ThemeData.light()`と`ThemeData.dark()`っていう名前付きコンストラクタがあるもんで
primaryColorだけ`copyWith()`して変更してやれば簡単にダークテーマ作れるもんだと思ったら、
これデフォルトカラー（ライトブルー）をベースにただ`Brightness`だけ`light`と`dark`切り分けてるだけなんですよね。

```dart:theme_data.dart
  /// A default light blue theme.
  ///
  /// This theme does not contain text geometry. Instead, it is expected that
  /// this theme is localized using text geometry using [ThemeData.localize].
  factory ThemeData.light() => ThemeData(brightness: Brightness.light);

  /// A default dark theme with a teal accent color.
  ///
  /// This theme does not contain text geometry. Instead, it is expected that
  /// this theme is localized using text geometry using [ThemeData.localize].
  factory ThemeData.dark() => ThemeData(brightness: Brightness.dark);
```

実際にはそれを基準に文字色を決めてたりするんですけど、最初それに気づかなくて
「あれれ〜、なんでアイコンだけ文字黒いんだ〜？おかしいぞ〜？」と阿○博士もびっくりの無知小学生っぷりを発揮してしまい
云々唸った記憶があります。~~ドキュメント読めってそれ100万回言われてるから。~~

結局、ダークテーマ用に個別に指定しました。

```dart
const MaterialColor _primaryColor = Colors.deepOrange;

ThemeData createThemeData(bool isDark) {
  final Brightness _primaryColorBrightness = ThemeData.estimateBrightnessForColor(_primaryColor);
  final Color _primaryColorLight = _primaryColor[100];
  final Color _primaryColorDark = _primaryColor[700];
  final Color _toggleableActiveColor = _primaryColor[600];
  final Color _accentColor = _primaryColor[600];
  final Brightness _accentColorBrightness = ThemeData.estimateBrightnessForColor(_accentColor);
  final Color _canvasColor = isDark ? Colors.grey[850] : Colors.grey[50];
  final Color _cardColor = isDark ? Colors.grey[800] : Colors.white;
  final Color _divideColor = isDark ? const Color(0x1FFFFFFF) : const Color(0x1F000000);
  final Color _highlightColor = isDark ? const Color(0x66BCBCBC) :const Color(0x40CCCCCC) ;
  final Color _splashColor = isDark ? const Color(0x40CCCCCC) : const Color(0x66C8C8C8);
  final Color _selectedRowColor = _primaryColor[100];
  final Color _unselectedWidgetColor = isDark ? Colors.white70 : Colors.black54;
  final Color _disabledColor = isDark ? Colors.white30 : Colors.black26;
  final Color _buttonColor = isDark ? _primaryColor[600] : Colors.grey[300];
  final IconThemeData _iconTheme = IconThemeData(color: _primaryColor);
  final Color _secondaryHeaderColor = isDark ? Colors.grey[700] : _primaryColor[50];
  final Color _textSelectionColor = isDark ? _primaryColor[600] : _primaryColor[200];
  final Color _textSelectionHandleColor = _primaryColor[300];
  final Color _backgroundColor = isDark ? Colors.grey[700] : _primaryColor[200];
  final Color _dialogBackgroundColor = isDark ? Colors.grey[800] : Colors.white;
  final Color _indicatorColor = _accentColor == _primaryColor ? Colors.white : _accentColor;
  final Color _hintColor = isDark ?  const Color(0x80FFFFFF) : const Color(0x8A000000);
  final Color _errorColor = Colors.red[700];
  final TargetPlatform _platform = TargetPlatform.android;
  final Typography _typography = Typography(platform: _platform);
  final TextTheme _textTheme = isDark ? _typography.white : _typography.black;
  final TextTheme _primaryTextTheme = _typography.white;
  final TextTheme _accentTextTheme = _typography.white;
  return ThemeData(
    primaryColor: _primaryColor,
    primaryColorBrightness: _primaryColorBrightness,
    primaryColorLight: _primaryColorLight,
    primaryColorDark: _primaryColorDark,
    toggleableActiveColor: _toggleableActiveColor,
    accentColor: _accentColor,
    accentColorBrightness: _accentColorBrightness,
    canvasColor: _canvasColor,
    scaffoldBackgroundColor: _canvasColor,
    bottomAppBarColor: _cardColor,
    cardColor: _cardColor,
    dividerColor: _divideColor,
    highlightColor: _highlightColor,
    splashColor: _splashColor,
    selectedRowColor: _selectedRowColor,
    unselectedWidgetColor : _unselectedWidgetColor,
    disabledColor : _disabledColor,
    buttonColor : _buttonColor,
    iconTheme: _iconTheme,
    secondaryHeaderColor: _secondaryHeaderColor,
    textSelectionColor: _textSelectionColor,
    textSelectionHandleColor: _textSelectionHandleColor,
    backgroundColor: _backgroundColor,
    dialogBackgroundColor: _dialogBackgroundColor,
    indicatorColor: _indicatorColor,
    hintColor: _hintColor,
    errorColor: _errorColor,
    textTheme: _textTheme,
    primaryTextTheme: _primaryTextTheme,
    accentTextTheme: _accentTextTheme,
  );
}

final _lightTheme = createThemeData(false);

final _darkTheme = createThemeData(true);
```

いくら勉強不足だとはいえ、流石にこれはひどい。

### リストボックス

![Screenshot_20181213-232638.jpg](https://qiita-image-store.s3.amazonaws.com/0/129315/6a94e636-0c9c-1912-80f3-3a3c07eb9481.jpeg)

リストボックスも作り方がドキュメント読んでも低知能すぎてよくわからなかったのですが、どうやらこうやるらしいです。

ドロップダウン（ボタン）、子要素、イベントハンドラを決める

```dart
    DropdownButton<Row> _buildDropdownButton() { // DropdownButton<子要素のWidget型>
      return DropdownButton<Row>(
        items: _timelineItems, // リスト項目 List<DropdownMenuItem<子要素のWidget型>>
        value: _currentTitle, // 現在選択されている要素 初期表示時はデフォルトをセット
        onChanged: (Row title) { // 選択項目変更時のイベントハンドラ 引数は子要素のWidget
          setState(() => _currentTitle = title);
        },
      );
    }
```

子要素を決める

```dart
  // ドロップダウンの要素 タイムライン
  final _timelineSets = <IconData, String>{
    Icons.home: 'home',
    Icons.message: 'local',
    Icons.share: 'social',
    Icons.language: 'global'
  };
```

```dart
  // 子要素のセットから実際にセットしたいRow型のWidgetに変換する
  // key, child, valueを３つセットで指定してあげないと思った通りの挙動にならない
  static List<DropdownMenuItem<Row>> _timelineItems = List.from(
    _timelineSets.entries.map<DropdownMenuItem<Row>>(
          (MapEntry<IconData, String> entry) => DropdownMenuItem<Row>(
        key: Key(entry.value),
        child: _createTimelineItem(entry.key, entry.value),
        value: _createTimelineItem(entry.key, entry.value),
      ),
    ),
  );
```

```dart
  // アイコンとラベルからRow型のWidgetに変換する
  // RowはList<Widget>型をchildrenに指定する
  static Row _createTimelineItem(IconData icon, String label) {
    final Color _timelineItemColor = Colors.grey[50];
    return Row(
      children: <Widget>[
        Icon(
          icon,
          color: _timelineItemColor,
        ),
        Text(
          label,
          style: TextStyle(
            color: _timelineItemColor,
          ),
        )
      ],
    );
  }
```

デフォルトの子要素を設定

```dart
  @override
  void initState() {
    super.initState();
    _currentTitle = _timelineItems.first.value;
  }
```

### ハンバーガーメニュー（ドロワー）

![Screenshot_20181213-232644.jpg](https://qiita-image-store.s3.amazonaws.com/0/129315/13a7fee0-0f80-8768-ec83-c31b790151e3.jpeg)

まぁつらつら並んでますが、**この中で動作するのはdark themeだけ**です。

他のは遷移を伴うので~~めんどくさかった~~後回しにしようと思っていました。

個人的なハマりポイントとしては、ドロワーを使う場合**Scaffold**のdrawerにリストを追加してやらんと
表示されないというところでした。

```dart
    return MaterialApp(
      title: 'Mrkey is an unofficial misskey client.',
      theme: _currentTheme,
      home: Scaffold(
        key: _scaffoldKey,
        appBar: AppBar(
          actions: _appBarItems,
        ),

        drawer: Theme( // drawerを指定しないと出ない（※一応他のやり方もあるがここでは割愛）
          data: _currentTheme.copyWith(
              canvasColor: _currentTheme.primaryColor,
              iconTheme: IconThemeData(
                  color: Colors.grey[50]
              )
          ),
          child: Drawer( // Drawerを指定
            child: ListView( // Drawerの子要素はListView
              children: _drawerItems, // ListViewに表示するリスト
              shrinkWrap: true,
            ),
          ),
        ),

      ),
    );
```

ListViewに表示するリストをしこしこ作る

```dart

// ListViewに表示する項目のクラス

enum OnTap { pop, toggleTheme }

class _DrawerItem {

  final IconData icon;
  final String label;
  final OnTap onTap;

  const _DrawerItem(this.icon, this.label, this.onTap);
}
```

```dart
// 表示する項目の一覧
const List<_DrawerItem> _drawerList = <_DrawerItem>[
  _DrawerItem(Icons.home, 'timeline', OnTap.pop),
  _DrawerItem(Icons.notifications, 'notifications', OnTap.pop),
  _DrawerItem(Icons.message, 'message', OnTap.pop),
  _DrawerItem(Icons.games, 'game', OnTap.pop),
  _DrawerItem(Icons.widgets, 'widgets', OnTap.pop),
  _DrawerItem(Icons.favorite, 'favofites', OnTap.pop),
  _DrawerItem(Icons.list, 'lists', OnTap.pop),
  _DrawerItem(Icons.cloud, 'drive', OnTap.pop),
  _DrawerItem(Icons.search, 'search', OnTap.pop),
  _DrawerItem(Icons.build, 'config', OnTap.pop),
  _DrawerItem(Icons.brightness_3, 'dark theme', OnTap.toggleTheme), // こいつだけタップした時の挙動が違う
  _DrawerItem(Icons.new_releases, 'about misskey', OnTap.pop),
];
```

```dart
    // リスト項目をせっせこ作る
    // リスト項目はListTile型
    // 今思うと完全に二度手間だなこれ
    _drawerItems.addAll(
        _drawerList.map<ListTile>((drawerItem) =>
            _buildDrawerItem(
                drawerItem.icon,
                drawerItem.label,
                drawerItem.onTap
            )
        ).toList()
    );

    // テーマ切り替えボタン
    final _toggleTheme = () => _currentTheme = _currentTheme == _lightTheme ? _darkTheme : _lightTheme;

    // せっせこ作る本体
    ListTile _buildDrawerItem(IconData icon, String label, OnTap onTap) {
      return ListTile(
        key: Key(label),
        leading: Padding(
          padding: EdgeInsets.all(_drawerItemPadding),
          child: Icon(
            icon,
            color: Colors.grey[50],
          ),
        ),
        selected: _currentDrawerItem == null ? false : _currentDrawerItem == label,
        title: Row(
          children: <Widget>[
            Expanded(
              child: Row(
                children: <Widget>[
                  Text(
                    label,
                    style: TextStyle(
                        color: Colors.grey[50]
                    ),
                  ),
                ],
              ),
              flex: 10,
            ),
            Expanded(
              child: Icon(Icons.chevron_right),
            ),
          ],
        ),
        onTap: () {
          setState(() {
            _currentDrawerItem = label;
            onTap == OnTap.pop ? Navigator.pop(context) : _toggleTheme();
          });
        },
      );
    }
```

うーん、これはひどい

## とまぁ、色々やってたわけですが

dart自体はすごく好きです。言語仕様にやや賛否両論あるとは思いますが、
個人的には業務でメインで使っているJavaのイマイチだなぁと思っているところがほぼカバーされつつも、
Javaのいいところを程よく残してくれているので、個人的な学習ハードルは低いと感じました。

ただ、Flutterとなるとそれに加えてFlutter独自のお作法があるので
普段社畜としてひいひい言ってる身からするとまだまだ勉強が足りないなぁと感じた次第です。

あまりにゴミすぎるのでソースをあげることはしませんが、
リファクタリングして最低限人に見せられるようになったらGithubにでもあげようと思います。

~~MisskeyがPWA対応しているのに、需要があるのかはわかりませんが~~