Dart
Flutter
misskey

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


はじめに

この記事はFlutter #2 Advent Calendar 2018の13日目の記事です。

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

先に言っておきますが、我こそはFlutterの伝道師や!と思っていらっしゃる方は読まなくても困りませんし、

おそらく不快指数をあげる事もないでしょう。

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


misskey

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


Misskeyは、地球で生まれた分散マイクロブログSNSです。Fediverse(様々なSNSで構成される宇宙)の中に存在するため、他のSNSと相互に繋がっています。

出典: https://joinmisskey.github.io/ja/


ご存知ない方はsyuioさん作の分散型Twitter(マストドン的な)と思って頂ければ近いかなと。

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

私があまり何か言っても野暮なので、一度触ってもらえればと思いますが、

個人的に開発者の方の遊び心が随所に見えて、触ってて楽しくて好きです。

藍ちゃんかわいい。


本題

今年の夏、Flutterが熱かったので、

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

アプリ名はMisskeyとの言葉遊びでMrkeyとしてみました。

APIの公式ドキュメントが充実してたので頑張れば作れると思ったのですが、

私の元気とやる気が持続しなかったため、中途半端な状態で放置したまま

ガワ(それも一部)しか作れていません。

ほんとはちゃんとしたのを作ってドヤりたかったのですが、できませんでした。

僕と皆さんの反面教師にしていただくべく、あえて晒します。


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



とりあえず起動画面

Screenshot_20181213-232621.jpg

AppBarにハンバーガーメニューボタン、タイムラインのドロップダウン、投稿ボタン、ダークテーマ切り替えボタン(後述)を並べてみました。

ただ、今思うと、投稿ボタンは右下に配置した方が使い勝手良いかな、と思います。

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

    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

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

というのもFlutterのMaterialパッケージに用意されているThemeDataクラスで

ThemeData.light()ThemeData.dark()っていう名前付きコンストラクタがあるもんで

primaryColorだけcopyWith()して変更してやれば簡単にダークテーマ作れるもんだと思ったら、

これデフォルトカラー(ライトブルー)をベースにただBrightnessだけlightdark切り分けてるだけなんですよね。


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万回言われてるから。

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

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

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

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

    DropdownButton<Row> _buildDropdownButton() { // DropdownButton<子要素のWidget型>

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

子要素を決める

  // ドロップダウンの要素 タイムライン

final _timelineSets = <IconData, String>{
Icons.home: 'home',
Icons.message: 'local',
Icons.share: 'social',
Icons.language: 'global'
};

  // 子要素のセットから実際にセットしたいRow型のWidgetに変換する

// key, child, valueを3つセットで指定してあげないと思った通りの挙動にならない
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),
),
),
);

  // アイコンとラベルから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,
),
)
],
);
}

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

  @override

void initState() {
super.initState();
_currentTitle = _timelineItems.first.value;
}


ハンバーガーメニュー(ドロワー)

Screenshot_20181213-232644.jpg

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

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

個人的なハマりポイントとしては、ドロワーを使う場合Scaffoldのdrawerにリストを追加してやらんと

表示されないというところでした。

    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に表示するリストをしこしこ作る


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

enum OnTap { pop, toggleTheme }

class _DrawerItem {

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

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

// 表示する項目の一覧

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),
];

    // リスト項目をせっせこ作る

// リスト項目は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対応しているのに、需要があるのかはわかりませんが