Flutter + Firebaseで簡易な記事管理アプリを作る

## tl;dr

Evernoteヘビーユーザーだけど最近使いにくいし、ほとんどの機能は最近使っていない事に気づきました。
実情記事を集めて管理したいだけなので、それならFlutter + Firebaseでさくっと作れるか試してみました。
[作ったアプリのソース](https://github.com/festa78/flutter_myapp)。

## 環境
Windows 10 Home 19041 build
firebase version 8.7.0
flutter doctor

```shell
Doctor summary (to see all details, run flutter doctor -v):
[√] Flutter (Channel beta, 1.20.0, on Microsoft Windows [Version 10.0.19041.388], locale ja-JP)
 
[!] Android toolchain - develop for Android devices (Android SDK version 29.0.3)
    X Android license status unknown.
      Try re-installing or updating your Android SDK Manager.
      See https://developer.android.com/studio/#downloads or visit
      https://flutter.dev/docs/get-started/install/windows#android-setup for detailed instructions.
[√] Chrome - develop for the web
[!] Android Studio (not installed)
[√] VS Code (version 1.47.3)
[√] Connected device (3 available)
```

_NOTE_: 今回webベースで開発したためflutter beta channelを利用しました。

## [Flutter] まずデモアプリを表示
公式サイトの[チュートリアル](https://flutter.dev/docs/get-started/web)に沿ってカウントアップのデモアプリをforkしてきます。
初めは数字とそのカウントアップのためのボタンがセットされているScaffoldクラスを少し改造してダミーのitem list表示するようにします。

```dart
class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  final String title;
  final List<String> entries = <String>['A', 'B', 'C'];
  final List<String> tags = <String>['tagA', 'tagB', 'tagC'];
  final List<int> colorCodes = <int>[100, 100, 100];

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

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: ListView.separated(
        padding: const EdgeInsets.all(8),
        itemCount: widget.entries.length,
        itemBuilder: (BuildContext context, int index) {
          return Container(
            height: 30,
            color: Colors.amber[widget.colorCodes[index]],
            child: Center(child: Text('Entry ${widget.entries[index]}')),
          );
        },
        separatorBuilder: (BuildContext context, int index) => Divider(
          color: Colors.grey[300],
          height: 5,
          thickness: 2,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/05c77d53-7070-0b59-6b18-bd4a534152ab.png)


## [Firebase] Firestoreで記事リスト作成
Firestoreから実際の記事がとってこれるように、一度ダミーの記事リストを作成します。
まずはFirebase consoleでプロジェクトを作成しておきます。
プロジェクト内のFirestoreにて新しくデータベースを作成しいくつかダミー記事を突っ込んでおきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/52cfe318-b008-7127-17fa-4ea725074824.png)


この時、記事タイトルの"title"や記事内容をひとまず詰める予定の"text", "html"等、タグ管理のための"tags"、最後にtimestampなどを決め打ちでキーとして追加しておきます。

## [Flutter+Firebase] Firestoreの記事リストを表示

[このページ](https://firebase.google.com/docs/web/setup#prerequisites)に沿ってindex.htmlへFirebaseのプロジェクト情報を追加しておきます。

Firestoreから得た記事リストを表示するクラスを追加します。
こちらはStreamBuilderを利用してFirestoreからの返り値を処理します。

```dart
class ItemList extends StatelessWidget {
  ItemList();

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: Firestore.instance
          .collection('test_list')
          .orderBy('timestamp', descending: true)
          .where('tags', arrayContainsAny: this.queryTags)
          .snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        if (snapshot.hasError) {
          print(snapshot.error);
          return new Text('Error: ${snapshot.error}');
        }
        switch (snapshot.connectionState) {
          case ConnectionState.waiting:
            return new Text('Loading...');
          default:
            return new ListView(
              shrinkWrap: true,
              children: snapshot.data.documents
                  .map((DocumentSnapshot document) {
                return new GestureDetector(
                  onTap: () => print('tapped'),
                  child: new Card(
                    child: ListTile(
                      title: new Text(document['title']),
                      subtitle: new Text(document['tags']
                              .join(',')),
                    ),
                  ),
                );
              }).toList(),
            );
        }
      },
    );
  }
}
```

`_MyHomePageState`内`Scaffold`関数の`body`を修正して表示します。
（ついでに後で使うテキストボックスも追加）

```dart
class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      body: Column(children: <Widget>[
        TextField(
          decoration: InputDecoration(
              border: InputBorder.none,
              hintText: 'Input filter query. Ex) label:inbox kitten'),
          onSubmitted: (String value) => print('hit'),
        ),
        ItemList(),
      ]),
      ...
    );
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/15476564-edd5-0fa3-19a6-f9ca6ae10126.png)

## [Flutter+Firebase] 記事のフィルタリング機能の追加

タグ情報やタイトルを用いた簡単なフィルタリング機能を追加します。
先ほどさりげなく追加しておいたテキストボックスを拡張してフィルタを可能にします。

具体的にはスペースで区切ったクエリをパースして取得します。
そしてlabel:nameの形式で$nameのタグのみを表示できるようにします。
それ以外はタイトルへの文字列検索を行ってフィルタをします。

まず`_MyHomePageState`でテキスト入力をパースしてクエリを取得します。
この時、デフォルトの全表示を可能にするため便宜的に`__all__`タグをデフォルトで追加しました。

```dart
class _MyHomePageState extends State<MyHomePage> {
  List<String> _queryTerms = [];
  List<String> _queryTags = ['__all__'];

  void _setQuery(String query) {
    setState(() {
      this._queryTags.clear();
      this._queryTerms.clear();

      query.split(' ').forEach((element) {
        if (element.startsWith(widget.tagPrefix)) {
          this._queryTags.add(element.replaceFirst(widget.tagPrefix, ''));
        } else {
          this._queryTerms.add(element);
        }
      });

      if (this._queryTags.isEmpty) {
        this._queryTags.add('__all__');
      }
    });
    print('tags: ${this._queryTags}');
    print('terms: ${this._queryTerms}');
  }
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      body: Column(children: <Widget>[
        TextField(
          decoration: InputDecoration(
              border: InputBorder.none,
              hintText: 'Input filter query. Ex) label:inbox kitten'),
          onSubmitted: (String value) => this._setQuery(value),
        ),
        ItemList(this._queryTags, this._queryTerms),
      ]),
      ...
    );
  }
}
```

`ItemList`クラスもクエリを受け付けてフィルタをかける機能を追加します。

```dart
class ItemList extends StatelessWidget {
  ItemList(this.queryTags, this.queryTerms);

  final List<String> queryTags;
  final List<String> queryTerms;
  ...
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: Firestore.instance
          .collection('test_list')
          .orderBy('timestamp', descending: true)
          .where('tags', arrayContainsAny: this.queryTags)
          .snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        ...
        switch (snapshot.connectionState) {
          case ConnectionState.waiting:
            return new Text('Loading...');
          default:
            return new ListView(
              shrinkWrap: true,
              children: snapshot.data.documents
                  .where((document) =>
                      document['tags'].toSet().containsAll(this.queryTags))
                  .where((document) => this
                      .queryTerms
                      .every((term) => document['title'].contains(term)))
                  .map((DocumentSnapshot document) {
                return new GestureDetector(
                  onTap: () => _navigate(context, document),
                  child: new Card(
                    child: ListTile(
                      title: new Text(document['title']),
                      subtitle: new Text(document['tags']
                              .where((tag) => tag != '__all__')
                              .join(',') +
                          ',__all__'),
                    ),
                  ),
                );
              }).toList(),
            );
        }
      },
    );
  }
}
```

![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c67de533-e37c-07eb-8cc6-9dab85ebb629.gif)


主な変更として、`StreamBuilder`に渡す`stream`はFirestoreへのクエリを利用してタグのフィルタリングを
適用するようにして受け取る記事リストを事前に制限するようにしました。
また`ListView`を表示する際に`where`を使って再度タグフィルタの確認とタイトル文字列とクエリ文字列の比較を
行ってフィルタリングを行っています。

Firestoreにはさらに`orderBy`での`timestamp`による整列を行っているため複合クエリを行っていることになります。
そのため、Firestore側で事前に[複合クエリのためのインデックス](https://firebase.google.com/docs/firestore/query-data/indexing)を作成する必要がありました。
ただし、エラーメッセージに言われるがままにインデックスを作成するだけですので特に大変な事はありません。

## [Flutter] `Navigator`で記事内容を表示

記事リストをタップするとその記事に登録された宛先やコンテンツを表示する機能を`Navigator`を用いて実装します。

```dart
class ItemList extends StatelessWidget {
  ...
  _navigate(context, document) async {
    final content = await _getContent(document);
    await Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => ContentViewer(
          document,
          content,
        ),
      ),
    );
  }

  _getContent(document) async {
    if (await canLaunch(document['uri'])) {
      return EasyWebView(
        src: document['uri'],
        onLoaded: () => (print('loaded uri')),
      );
    } else if (!document['html'].isEmpty) {
      return EasyWebView(
        src: document['html'],
        isHtml: true,
        onLoaded: () => (print('loaded html')),
      );
    } else if (!document['text'].isEmpty) {
      return SingleChildScrollView(
        scrollDirection: Axis.vertical,
        child: Text(document['text']),
      );
    } else {
      throw 'Cannot read document $document';
    }
  }  ...
}
```

ここでは`EasyWebView`, `SingleChildScrollView`を用いてweb content、html（メール等）、その他textを
ひとまず表示できるようにしました。

実際にコンテンツを描画する`ContentViewer`は以下のように実装します。

```dart
class ContentViewer extends StatelessWidget {
  const ContentViewer(this.document, this.content);

  final DocumentSnapshot document;
  final Widget content;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(this.document['title']),
      body: this.content,
    );
  }
}
```

結果以下のように表示できました。
![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c5c89c39-deb4-9bed-62ec-b246de6921f3.gif)

## [Flutter+Firebase] 記事タグを編集、更新

各記事に登録されているタグ情報を編集、更新できるようにします。

新たに`StatefulWidget`として`TagEditor`クラスを追加します。
`TagEditor`クラス内では、`FutureBuilder`を通してタグ情報をFirestoreから取得し、
その内容を表示します。

最上段のテキストボックスで入力を受け付けて、新しいタグ情報加えます。
既存のタグは、そのカードをタップすると消せるようにします。（TODO:もっと分かりやすい消し方）

右上に`update` iconを追加し、そのボタンをタップすると初めてFirestoreへ更新リクエストを送ります。
デフォルトの`Back`ボタンを押した場合は更新せずに戻ります。

`__all__`タグはフィルタリング時に利用するため全ての記事に登録されている必要があります。
なので、これは消せないようにします。

```dart
class TagEditor extends StatefulWidget {
  TagEditor(this.documentID);

  final String documentID;

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

class _TagEditorState extends State<TagEditor> {
  Future<List<dynamic>> _tags;

  @override
  void initState() {
    super.initState();

    this._tags = Firestore.instance
        .collection('test_list')
        .document(widget.documentID)
        .get()
        .then((doc) {
      var tags = doc.data['tags'];
      tags.sort();
      tags.removeAt(tags.indexOf('__all__'));
      tags.add('__all__');
      return tags;
    });
  }

  void _removeTag(int index) {
    // Needs at least '__all__' tag to avoid filter error.
    // Do not allow to delete.
    setState(() {
      this._tags = this._tags.then((tags) {
        if (tags[index] != '__all__') {
          tags.removeAt(index);
        }
        return tags;
      });
    });
  }

  void _addTag(String tagName) {
    setState(() {
      this._tags = this._tags.then((tags) {
        tags.add(tagName);
        tags.sort();
        tags.removeAt(tags.indexOf('__all__'));
        tags.add('__all__');
        return tags;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: this._tags,
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return CircularProgressIndicator();
        }

        if (snapshot.hasError) {
          return Text(snapshot.error.toString());
        }

        if (!snapshot.hasData) {
          return Text('Missing data');
        }

        return Scaffold(
          appBar: AppBar(
            title: Text('Edit tags'),
            actions: <Widget>[
              IconButton(
                icon: Icon(
                  Icons.update,
                  size: 24.0,
                ),
                onPressed: () {
                  print(
                      'Assign tags ${snapshot.data} to document ${widget.documentID}');
                  Firestore.instance
                      .collection('test_list')
                      .document(widget.documentID)
                      .updateData({
                    'tags': snapshot.data,
                    'timestamp': FieldValue.serverTimestamp(),
                  });
                  Navigator.of(context).pop();
                },
              ),
            ],
          ),
          body: Column(
            children: <Widget>[
              TextField(
                textInputAction: TextInputAction.done,
                onSubmitted: (String tagName) {
                  if (tagName.isNotEmpty && !snapshot.data.contains(tagName)) {
                    this._addTag(tagName);
                  }
                },
                decoration: InputDecoration(
                    border: InputBorder.none,
                    hintText: 'Enter a tag name to add'),
              ),
              ListView.builder(
                shrinkWrap: true,
                itemCount: snapshot.data.length,
                itemBuilder: (BuildContext context, int index) {
                  return new GestureDetector(
                    onTap: () => this._removeTag(index),
                    child: new Card(
                      child: ListTile(
                        title: new Text(snapshot.data[index]),
                      ),
                    ),
                  );
                },
              ),
            ],
          ),
        );
      },
    );
  }
}
```

あとは`ContentViewer`に`List` iconを追加してそこから呼べるようにして編集機能を追加しました。

![ezgif.com-video-to-gif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/8cd53fec-1d6d-a843-6511-d8607159a9ca.gif)


## [Flutter+Firebase] Navigation Barを用意してタグリストを表示

トップページからタグ一覧をNavigation Barより見れるようにします。

Firestoreにてタグ一覧のデータベース`tag_list`を記事リスト`test_list`から
自動生成するようなCloudFunctionを加えます。

ここで加える関数`tagListOnUpdate`は`test_list`内の各記事に更新がかかる度に実行され、
tag情報の差分を抽出し、その差分に基づき`tag_list`データベースへ更新を加えます。

```javascript
exports.tagListOnUpdate = functions.firestore
  .document('test_list/{docId}')
  .onUpdate((change, context) => {
    const newValue = change.after.data();
    const previousValue = change.before.data();

    const newTags = new Set(newValue['tags']);
    const previousTags = new Set(previousValue['tags']);

    const removedTags = new Set([...previousTags].filter(x => !newTags.has(x)));
    const addedTags = new Set([...newTags].filter(x => !previousTags.has(x)));

    console.log('removedTags: ', removedTags);
    console.log('addedTags: ', addedTags);

    const tagListRef = fireStore.collection('tag_list');

    removedTags.forEach((tag) => {
      tagListRef.doc(tag).get()
        .then((tagDoc) => {
          if (tagDoc.exists) {
            console.log('doc exists when removing: ', tagDoc.data()['documents']);

            const tagDocuments = tagDoc.data()['documents'].filter(
              v => v !== context.params.docId);

            console.log('setting docs ', tagDocuments, 'from tag ', tag, 'after removal of ', context.params.docId);

            if (tagDocuments.length) {
              console.log('After removal, doc should still exist.');
              tagListRef.doc(tag).set({
                documents: tagDocuments,
                timestamp: admin.firestore.FieldValue.serverTimestamp(),
              });
            } else {
              console.log('No documents belong to this tag. remove tag document itself');
              tagListRef.doc(tag).delete();
            }
          }
          return;
        })
        .catch((error) => console.log('error tag update:', error));
      });

    addedTags.forEach((tag) => {
      tagListRef.doc(tag).get()
        .then((tagDoc) => {
          let tagDocuments = [];
          if (tagDoc.exists) {
            console.log('doc exists when adding: ', tagDoc.data()['documents']);
            tagDocuments = tagDocuments.concat(tagDoc.data()['documents']);
          }

          if (!tagDocuments.includes(context.params.docId)) {
            tagDocuments.push(context.params.docId);
          }

          console.log('setting docs ', tagDocuments, 'to tag ', tag, 'after addition of ', context.params.docId);

          return tagListRef.doc(tag).set({
            documents: tagDocuments,
            timestamp: admin.firestore.FieldValue.serverTimestamp(),
          });
        })
        .catch((error) => console.log('error tag update:', error));
      });
  })
```

Navigator Bar上でタグ一覧をFirestoreの`tag_list`データベースに基づき表示するクラスも
以下のように`StreamBuilder`を用いて実装します。

```dart
class NavDrawer extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Drawer(
        child: ListView(padding: EdgeInsets.zero, children: <Widget>[
      DrawerHeader(
        child: Text(
          'Tag list',
          style: TextStyle(color: Colors.white, fontSize: 25),
        ),
        decoration: BoxDecoration(
          color: Colors.green,
        ),
      ),
      StreamBuilder<QuerySnapshot>(
          stream: Firestore.instance.collection('tag_list').snapshots(),
          builder:
              (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
            if (snapshot.hasError) return new Text('Error: ${snapshot.error}');
            switch (snapshot.connectionState) {
              case ConnectionState.waiting:
                return new Text('Loading...');
              default:
                return new ListView(
                  shrinkWrap: true,
                  children:
                      snapshot.data.documents.map((DocumentSnapshot document) {
                    return new GestureDetector(
                        child: new Card(
                            child: ListTile(
                                title: new Text(document.documentID),
                                subtitle: new Text(
                                    '(${document['documents'].length})'))));
                  }).toList(),
                );
            }
          })
    ]));
  }
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c26c20d6-dc3d-481d-5b7c-f92b7c70033e.png)

Firestore上は以下のようになっています。`documents`キーが示す配列は対応するタグが登録されている記事一覧です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c6aebc20-eb29-2682-cc92-313821c3a764.png)


## [Flutter+Firebase] 手動で記事を追加

トップページのプラスボタンから、手動で記事を追加できるようにします。

[Flutter Html Editor](https://pub.dev/packages/html_editor)が残念ながらWeb版に対応していなかったため、
今回はURLとraw textのみ追加できるようにしました。

(TODO: PDF追加)

text editorとして、TextField経由で入力を受け付けて、update iconからFirestoreへ更新を投げる
`LaunchTextEditor`クラスに実装します。

```dart
class LaunchTextEditor extends StatelessWidget {
  LaunchTextEditor({this.initialTitle, this.initialContent});

  final String initialTitle;
  final String initialContent;

  @override
  Widget build(BuildContext context) {
    final titleController = TextEditingController(text: this.initialTitle);
    final contentController = TextEditingController(text: this.initialContent);

    return Scaffold(
      appBar: AppBar(
        title: TextField(controller: titleController),
        actions: <Widget>[
          IconButton(
            icon: Icon(
              Icons.update,
              size: 24.0,
            ),
            onPressed: () async {
              print('title: ${titleController.text}');
              print('content: ${contentController.text}');
              await Firestore.instance.collection('test_list').add({
                'title': titleController.text,
                'uri': '',
                'html': '',
                'text': contentController.text,
                'tags': ['__all__'],
                'timestamp': FieldValue.serverTimestamp(),
              });
              Navigator.of(context).pop();
            },
          ),
        ],
      ),
      body: TextField(
        controller: contentController,
        decoration: InputDecoration(
          border: InputBorder.none,
          hintText: 'Enter text',
        ),
        keyboardType: TextInputType.multiline,
        maxLines: null,
      ),
    );
  }
}
```

続いて、トップページのプラスボタンよりダイアログベースで誘導しつつ
URLとtextを追加できるようにします。

```dart
enum ArticleTypes {
  URL,
  PDF,
  TEXT,
}

class _MyHomePageState extends State<MyHomePage> {
  ...
  _createDialogOption(
      BuildContext context, ArticleTypes articleType, String str) {
    return new SimpleDialogOption(
      child: new Text(str),
      onPressed: () {
        Navigator.pop(context, articleType);
      },
    );
  }

  _addUrlDialog(BuildContext context) {
    final titleController = TextEditingController();
    final contentController = TextEditingController();

    return showDialog(
        context: context,
        builder: (context) {
          return AlertDialog(
            title: Text('Provide URL'),
            content: Column(
              mainAxisSize: MainAxisSize.min,
              children: <Widget>[
                TextField(
                  controller: titleController,
                  decoration: InputDecoration(hintText: "Title"),
                ),
                TextField(
                  controller: contentController,
                  decoration: InputDecoration(hintText: "URL path"),
                ),
              ],
            ),
            actions: <Widget>[
              new FlatButton(
                child: new Text('Add'),
                onPressed: () async {
                  print(
                      'Add new title ${titleController.text} with article ${contentController.text}');
                  await Firestore.instance.collection('test_list').add({
                    'title': titleController.text,
                    'uri': contentController.text,
                    'tags': ['__all__'],
                    'timestamp': FieldValue.serverTimestamp(),
                  });
                  Navigator.of(context).pop();
                },
              ),
              new FlatButton(
                child: new Text('CANCEL'),
                onPressed: () {
                  Navigator.of(context).pop();
                },
              ),
            ],
          );
        });
  }

  _addArticleDialog(BuildContext context) {
    showDialog<ArticleTypes>(
      context: context,
      builder: (BuildContext context) => new SimpleDialog(
        title: new Text('Select the content type'),
        children: <Widget>[
          _createDialogOption(context, ArticleTypes.URL, 'Url'),
          _createDialogOption(context, ArticleTypes.TEXT, 'Text')
        ],
      ),
    ).then((value) {
      switch (value) {
        case ArticleTypes.URL:
          print('url');
          _addUrlDialog(context);
          break;
        case ArticleTypes.PDF:
          print('pdf');
          // TODO: PDF storage.
          break;
        case ArticleTypes.TEXT:
          print('text');
          // TODO: switch to HTML editor.
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => LaunchTextEditor(
                initialTitle: 'New article',
                initialContent: 'New content',
              ),
            ),
          );
          break;
      }
    });
  }
  ...
}
```

![ezgif.com-video-to-gif (4).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c7805d2f-371f-f8d3-95c2-5b9dfd8efbcb.gif)

## [Flutter+Firebase] Emailを転送して記事リストに追加

Email経由で記事を追加できるようにします。
調べた所、Freenom(ドメイン取得）+ SendGrid(inbound parser） + Cloud function(parse, 記事追加）で行けるとの事でした。

Freenom, SendGridの設定は他記事がすでに詳しいので割愛します。
参考：
* [SendGrid 新人成長記　第七回　Inbound Parse Webhookを用いてメールを受信する](https://sendgrid.kke.co.jp/blog/?p=12114)
* [SendGrid,Heroku,Gmailを使って無料で特定のメールだけをSlackチャンネルに転送する](https://qiita.com/subesubemanzyu/items/31ae750f09bd56bb5f14)

唯一SendGridがCloudFunctionへ送ってくるメール情報のparse方法が分からなかったのですが、以下記事などを参考に
multipart/form-dataとしてbusboyでparseする事で上手くいきました。
* [How to parse multipart/form-data on firebase cloud functions?](https://stackoverflow.com/questions/51484307/how-to-parse-multipart-form-data-on-firebase-cloud-functions)

結果CloudFunctionでは以下のような関数を実装しました。

```javascript
exports.addMessage = functions.https.onRequest(async (req, res) => {
  try {
    console.log('Email recieved')

    console.log(req)
    console.log(req.headers)
    console.log(req.body.toString())

    const busboy_parser = new busboy({ headers: req.headers })
    let docRef = fireStore.collection('test_list').doc();
    docRef.set({
        tags: ['__all__'],
        timestamp: admin.firestore.FieldValue.serverTimestamp(),
      }, {merge: true})
      .then(() => console.log('added tags'))
      .catch((error) => console.log('error tags:', error));

    busboy_parser.on("field", (field, val) => {
      console.log(`Processed field ${field}: ${val}.`);
      if (field === 'subject') {
        console.log('find subject')
        docRef.set({title: val}, {merge: true})
          .then(() => console.log('added subject'))
          .catch((error) => console.log('error subject:', error));
      } else if (field === 'html') {
        console.log('find html')
        docRef.set({html: val}, {merge: true})
          .then(() => console.log('added html'))
          .catch((error) => console.log('error html:', error));
      } else if (field === 'text') {
        console.log('find text')
        docRef.set({text: val}, {merge: true})
          .then(() => console.log('added text'))
          .catch((error) => console.log('error text:', error));
      }
    })

    busboy_parser.end(req.rawBody)
  } finally {
    res.send(200);
  }
});
```

ただしこのやり方だと認証なしのHTTPリクエストを受け付けないと動かないのが問題でした。
GCPのservice account経由でトークンを発行してもSendGridからそれを利用できず。

以下の記事などですとURLに埋め込める範囲であればIDを差し込めそうなので何とでもできそうですが、ひとまず今回はこのメール転送機能は保留しました。
https://stackoverflow.com/questions/20865673/sendgrid-incoming-mail-webhook-how-do-i-secure-my-endpoint

## [Flutter+Firebase] Google認証を用いたログインを追加

以下のチュートリアルを参考にしてgoogle sign inを可能にしました。
* [Flutter Web: Firebase Authentication and Google Sign in](https://github.com/funwithflutter/tutorials/tree/master/001-Flutter-Web-Firebase-Auth-Google)

`Provider`を用いて認証状態管理し、ログインが必要な場合にのみlogin画面を先に表示できるようにします。

```dart
void main() => runApp(
      MultiProvider(
        providers: [
          Provider(
            create: (_) => FirebaseAuthService(),
          ),
          StreamProvider(
            create: (context) =>
                context.read<FirebaseAuthService>().onAuthStateChanged,
          ),
        ],
        child: MyApp(),
      ),
    );
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70838/c393180b-2707-ab03-c1ec-96ac78de0758.png)


`FirebaseAuthService`などの詳細は上記チュートリアルから丸パクさせてもらいました。

また以下の記事により、起動時のポートを固定する方が良いようです。（今回は3333に固定しています）
* [Flutter Web x Firebase Auth x Google認証を実装してみる](https://qiita.com/corocn/items/f420f32a1633f2c97b1d#%E8%B5%B7%E5%8B%95%E6%99%82%E3%81%AE%E3%83%9D%E3%83%BC%E3%83%88%E3%81%AE%E5%9B%BA%E5%AE%9A%E6%96%B9%E6%B3%95)

## [Firebase] UIDベースでアクセス制限を設定

前セクションでユーザー情報が追加できたので、そのユーザーIDに基づいてデータベースのアクセス制限を
Firestore上で設定します。

これは公式のドキュメントなどを読んでさくっと設定できました。
[Writing conditions for Cloud Firestore Security Rules](https://firebase.google.com/docs/firestore/security/rules-conditions)

## 感想

* FirebaseとFlutterはサクサク作れてとても良い。
* 作りこもうとするととても大変（ところどころに妥協が。。）
* Evernoteは偉大ｒｙ