flutter + firebase auth + firestore + provider で匿名ユーザーごとにデータを管理する

# 諸注意
オレオレ流です。誰か正解を教えて！
特に命名規則に自信がありません。〇〇serviceや〇〇processなど
lib配下に本来は用途ごとにディレクトリ分けるべきですが(pagesとかviewsとか)、この記事では分けていません。


# この記事ですること
flutterにおいてユーザー単位でデータを保持するにはどうしたらよいかという記事です。
データを保持するためにfirestoreを使用し、ユーザー単位でデータを持つために匿名ログインを使用します。
また、状態管理としてproviderを使用します。
サンプルとしてaddとdeleteができるTODOアプリをつくります。


# この記事でしないこと
firebaseやflutterの設定はしません。
firebaseはプロジェクトの作成とfirestoreの設定をしておいてください。
firestoreのセキュリティルールはテストモードにしてください。(記事内で正しく設定し直します)
本番環境と開発環境を分離したいって人は[flutter + firebaseで本番環境と開発環境を切り替える](https://qiita.com/ampersand-dev/items/39f7f6286058435a6bed)を参考にしてください。
この記事では本番と開発は分けていませんが、一応は[flutter + firebaseで本番環境と開発環境を切り替える](https://qiita.com/ampersand-dev/items/39f7f6286058435a6bed)の続きを想定した記事です。


# firestoreのセキュリティルールの設定
```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
	
  // ユーザーが認証されている場合はTRUE
function isUserAuthenticated(userId) {
      return request.auth.uid == userId;
    }
    // ユーザー認証されている場合に各権限を許可する
    match /users/{userId}/todo/{docId} {
       allow read, write: if isUserAuthenticated(userId);
    }
  }
}
```


# authの設定
匿名ログインを有効にします。簡単ですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409192/45a01d2f-8ee8-3dde-45ba-a0f6301ec12c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409192/9004d5fe-43b7-8e44-d028-0f4b0c165cb7.png)


# pubspec.yaml
```dart:pubspec.yaml
  cupertino_icons: ^0.1.2
  cloud_firestore: ^0.13.4+2
  firebase_auth: ^0.15.4
  apple_sign_in: ^0.1.0
  provider: ^4.0.4
```

# flutter
`/lib`配下に以下のファイルを作ってください。
`auth_service.dart`
`todo_service.dart`
`todo_model.dart`
`main.dart`


ソースコード貼り付けます。長いので折りたたんでいます。
<details><summary>auth_service.dart</summary><div>

```dart:auth_service.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

enum Status { uninitialized, authenticated, authenticating, unauthenticated }

// https://medium.com/flutter-community/flutter-firebase-login-using-provider-package-54ee4e5083c7
class AuthService with ChangeNotifier {
  final FirebaseAuth _auth;
  FirebaseUser _user;
  Status _status = Status.uninitialized;

  AuthService.instance() : _auth = FirebaseAuth.instance{
    _auth.onAuthStateChanged.listen(_onAuthStateChanged);
  }

  FirebaseUser get user => _user;
  FirebaseAuth get auth => _auth;
  Status get status => _status;

  // firebase auth側の匿名認証を有効にするのを忘れずに
  Future<void> signInAnonymously() async {
    try {
      _status = Status.authenticating;
     notifyListeners();
      await _auth.signInAnonymously();
      _status = Status.authenticated;
      notifyListeners();
    } catch (e) {
      print(e);
      _status = Status.unauthenticated;
      notifyListeners();

    }
  }

  Future<void> _onAuthStateChanged(FirebaseUser firebaseUser) async {
    if (firebaseUser == null) {
      _status = Status.unauthenticated;
    } else {
      _user = firebaseUser;
      _status = Status.authenticated;
    }
    notifyListeners();
  }
}

```
</div></details>


<details><summary>todo_service.dart</summary><div>

```dart:TodoService.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:how_many_minutes_left/todo_model.dart';

class TodoService extends ChangeNotifier {
  String uid;
  List _todo;

  TodoService();

  CollectionReference get dataPath =>
      Firestore.instance.collection('users/$uid/todo');
  List get todo => _todo;

  void init(List<DocumentSnapshot> documents) {
    _todo = documents.map((doc) => TodoModel.fromMap(doc)).toList();
  }

  void addTitle(title) {
    dataPath.document().setData({'title': title, 'createAt': DateTime.now()});
  }

  void deleteDocument(docId) {
    dataPath.document(docId).delete();
  }
}

```
</div></details>


<details><summary>todo_model.dart</summary><div>

```dart:todo_model.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class TodoModel {
  String _docId;
  String _title;
  Timestamp _createAt;

  TodoModel(
    this._docId,
    this._title,
    this._createAt,
  );

  String get docId => _docId;
  String get title => _title;
  Timestamp get createAt => _createAt;

  TodoModel.fromMap(map) {
    _docId = map.documentID;
    _title = map['title'];
    _createAt = map['createAt'];
  }

  Map<String, dynamic> toMap() {
    var map = <String, dynamic>{};
    map['title'] = _title;
    map['createAt'] = _createAt;
    return map;
  }
}

```
</div></details>


<details><summary>main.dart</summary><div>

```dart:main.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:how_many_minutes_left/todo_service.dart';
import 'package:provider/provider.dart';
import 'auth_service.dart';

// 匿名ユーザーごとに処理を分ける
void main() {
  runApp(
    // providerを複数使うときはMultiProviderを使う。
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthService.instance()),
        ChangeNotifierProvider(create: (_) => TodoService()),
      ],
      child: MyApp(),
    ),
  );
}

// 本番かリリースかを判断するには bool.fromEnvironment('dart.vm.product')を使う。
// よりわかりやすくするためにラップして使っている。
bool isRelease() {
  bool _bool;
  bool.fromEnvironment('dart.vm.product') ? _bool = true : _bool = false;
  return _bool;
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SignProcess(),
    );
  }
}

class SignProcess extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, AuthService authService, _) {
        // ログインの状態に応じて処理を遷移させる。
        switch (authService.status) {
          case Status.uninitialized:
            print('uninitialized');
            return Center(child: CircularProgressIndicator());
          case Status.unauthenticated:
          case Status.authenticating:
            print('anonymously');
            authService.signInAnonymously();
            return Center(child: CircularProgressIndicator());
          case Status.authenticated:
            print("authenticated");
            break; // DbProcess();へ進む

        }
        return DbProcess();
      },
    );
  }
}

class DbProcess extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final authService = Provider.of<AuthService>(context);
    final todoService = Provider.of<TodoService>(context);
    // firestoreのデータはuidごとに分けているので、データの取得前にtodoServiceにuidを渡してあげる
    todoService.uid = authService.user.uid;
    // streamのデータ(firestore)のデータが変更される度に自動でリビルドしてくれる
    return StreamBuilder<QuerySnapshot>(
      // firestoreからデータを拾ってくる
      stream: todoService.dataPath.orderBy("createAt").snapshots(),
      builder: (BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }
        switch (snapshot.connectionState) {
          case ConnectionState.waiting: // データの取得まち
            return CircularProgressIndicator();
          default:
            // streamからデータを取得できたので、使いやすい形にかえてあげる
            todoService.init(snapshot.data.documents);
            return ViewPage();
        }
      },
    );
  }
}

class ViewPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final todoService = Provider.of<TodoService>(context);

    return Scaffold(
      appBar: AppBar(
          title: Center(
              child: Text(
                  'firestoreの動作確認\n(${isRelease() ? 'リリース' : 'デバック'}モード)'))),
      body: Center(
        child: ListView.builder(
          itemCount: todoService.todo.length,
          itemBuilder: (BuildContext context, int index) {
            final _date = todoService.todo[index].createAt.toDate();
            return ListTile(
              title: Text(todoService.todo[index].title),
              subtitle: Text(_date.toString()),
              trailing: IconButton(
                icon: Icon(Icons.delete, color: Colors.red),
                onPressed: () {
                  todoService.deleteDocument(todoService.todo[index].docId);
                },
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.add),
        onPressed: () {
          showDialog(
            context: context,
            builder: (context) {
              return NameInputDialog();
            },
          );
        },
      ),
    );
  }
}

class NameInputDialog extends StatefulWidget {
  @override
  _NameInputDialogState createState() => _NameInputDialogState();
}

class _NameInputDialogState extends State<NameInputDialog> {
  TextEditingController _nameController = TextEditingController();
  @override
  Widget build(BuildContext context) {
    final todoService = Provider.of<TodoService>(context);
    return AlertDialog(
      title: Text('予定を入力してください'),
      content: TextField(
        controller: _nameController,
        autofocus: true,
        decoration: InputDecoration(
            border:
                OutlineInputBorder(borderRadius: BorderRadius.circular(16))),
      ),
      actions: <Widget>[
        FlatButton(
          child: Text('クリア'),
          onPressed: _nameController.clear,
        ),
        FlatButton(
          child: Text('決定'),
          onPressed: () {
            todoService.addTitle(_nameController.text);
            Navigator.pop(context);
          },
        ),
      ],
    );
  }
}

```
</div></details>


# 完成
右のゴミ箱ボタンで削除、右下の＋ボタンで追加、編集機能はつけていません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409192/966bcc27-8c75-0284-1c7d-8355613c4391.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409192/0c968b1a-8924-db17-9791-b28d8c9346cb.png)

# リンク

* [デザインを何もしらないエンジニアがDailyCocoda！を3ヶ月かけて完走したので、1日目の自分にアドバイスする その１ 概要編](https://qiita.com/ampersand-dev/items/e0768faf4b7260fc28d6)
* [[dart] mapでもindexがほしい - asMapは便利 -](https://qiita.com/ampersand-dev/items/7967433bdbef4a455901)
* [flutter + firebaseで本番環境と開発環境を切り替える](https://qiita.com/ampersand-dev/items/39f7f6286058435a6bed)
* [利益を上げている個人開発者とその代表的アプリのまとめ](https://qiita.com/ampersand-dev/items/f20e78896b868e9f2eb1)
* [メモの心理的ハードルを爆下げするテクニック -外部記憶装置としてのScrapbox (+ Alfred + chrome拡張)-](https://qiita.com/ampersand-dev/items/cb9c2a40f20967b1bdff)