RiverpodでFirestoreとFirebase AuthをStreamProviderで組み合わせる

# この記事は

先日書いた「[RiverpodのStreamProviderをモック化して、依存するProviderを単体テストする](https://qiita.com/tfandkusu/items/36640529294f65b6ae81)」という記事で、[Riverpod](https://riverpod.dev/)の[StreamProvider](https://pub.dev/documentation/riverpod/latest/all/StreamProvider-class.html)で[Cloud Firestore](https://firebase.google.com/docs/firestore?hl=ja)の[リアルタイムアップデート](https://firebase.google.com/docs/firestore/query-data/listen?hl=ja)を使う方法を解説しました。しかし、[Firebase Authentication](https://firebase.google.com/docs/auth?hl=ja)と組み合わせて、Firestoreのユーザ別の領域にアクセスするケースに対応した方法の紹介までは行えていませんでした。
この記事ではFirestoreのデータとFirebase Authenticationの認証状態を両方とも監視して、リアルタイムに表示に反映させる方法を解説します。さらにデータと認証状態を監視しているStreamProviderをモック化して、それを使って表示の制御を行っているProviderの単体テストも行います。想定しているアーキテクチャは[前回の記事のこちら](https://qiita.com/tfandkusu/items/36640529294f65b6ae81#%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%81%E3%83%A3)をご参照ください。


## サンプルアプリ

この記事で解説したい内容をサンプルアプリで説明します。

ある動物園が展示している動物一覧を提供しています(動物は[kotlin-faker](https://github.com/serpro69/kotlin-faker)で作成)。`Sign in with GitHub` ボタンでGitHubにログインすると、その人が付けた「いいね！」がリスト表示に適用されます。右上のログアウトボタンを押すと、最初の表示に戻ります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f05e7391-0f5f-78d9-16b9-2a2161c56aba.png" width="240px"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/385bb43b-d8d3-d46a-a892-e92755573a19.png" width="240px">

## データ

動物一覧はFirestoreでこのようなデータになっています。

### 動物

動物はanimalsコレクションのドキュメントに格納します。ドキュメントにはnameフィールドがあります。


![スクリーンショット 2020-12-26 20.31.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/c653c69a-b2b7-cd18-cf6d-8401a146b843.png)

### いいね！

いいね！はusersコレクションにUser IDと同名のドキュメントを作り、そこにlikesコレクションを作成し、いいね！した動物を参照するドキュメントを作成します。
animalIdフィールドがanimalsコレクションのドキュメントのIDです。

※ このスクリーンショットではUser IdをdummyUserとしています。

![スクリーンショット 2020-12-26 20.35.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/256b99b7-e186-180b-ac8a-1aa0645755bc.png)

## サンプルコード

今回の記事のサンプルコードです。Githubアカウントでログインできますが、簡単のため、全員ダミーユーザでログインした動作になります。

[tfandkusu/observe_firestore_with_auth](https://github.com/tfandkusu/observe_firestore_with_auth)

## 参考文献

この記事とサンプルアプリは、こちらのイギリスのGoogle Developer Expertの人が作成したサンプルコードを参考にして作成しました。

[bizz84/starter_architecture_flutter_firebase](https://github.com/bizz84/starter_architecture_flutter_firebase)

# StreamProviderを作成する

動物一覧のStreamProviderです。

```animal_repository.dart
final animalListStreamProvider = StreamProvider.autoDispose((_) {
  CollectionReference ref = FirebaseFirestore.instance.collection('animals');
  return ref.snapshots().map((snapshot) {
    final list = snapshot.docs
        .map((doc) => Animal(doc.id, doc.data()['name']))
        .toList();
    list.sort((a, b) => a.name.compareTo(b.name));
    return list;
  });
});
```

認証状態を取得するためのStreamProviderです。

```user_repository.dart
/// 認証状態プロバイダー
final authStreamProvider = StreamProvider.autoDispose((_) {
  return FirebaseAuth.instance.authStateChanges().map((user) => user != null);
});

/// UserIdプロバイダー
final userIdStreamProvider = StreamProvider.autoDispose((_) {
  return FirebaseAuth.instance.authStateChanges().map((user) {
    if (user != null) {
      return user.uid;
    } else {
      return null;
    }
  });
});
```

いいね！のStreamProviderです。いいね！が付いている動物のドキュメントIDセットを流します。

```like_repository.dart
final likeSetStreamProvider = StreamProvider.autoDispose((ref) {
  final userIdAsyncValue = ref.watch(userIdStreamProvider);
  var userId = userIdAsyncValue?.data?.value;
  if (userId != null) {
    CollectionReference cref =
        FirebaseFirestore.instance.collection('/users/$userId/likes');
    return cref.snapshots().map((snapshot) =>
        snapshot.docs.map((doc) => doc.data()['animalId']).toSet());
  } else {
    return Stream.value(Set<String>());
  }
});
```

いいね！のStreamProviderが提供するStreamはuserIdに依存します。そしてuserIdもStreamProviderが提供するStreamから流れてくるものです。当初、別のStreamProviderの最新の値に依存するStreamProviderの作り方が分からなかったですが、[bizz84/starter_architecture_flutter_firebase](https://github.com/bizz84/starter_architecture_flutter_firebase#creating-providers-with-riverpod)のおかげで分かりました。

# 表示を制御するためのProviderを作成する

Presenter層に相当するProviderの作り方は[前回](https://qiita.com/tfandkusu/items/36640529294f65b6ae81#presenter%E5%8D%98%E4%BD%93%E3%83%86%E3%82%B9%E3%83%88%E5%AF%BE%E8%B1%A1)と同様です。

```main_provider.dart
/// ログアウトボタン表示Provider
final logoutProvider = Provider.autoDispose((ref) async {
  // 認証状態ならば表示
  final futureAuth = ref.watch(authStreamProvider.last);
  return await futureAuth;
});

/// 動物といいね！のリスト表示Provider
final mainUiModelProvider = Provider.autoDispose((ref) async {
  // 動物リスト
  final futureAnimalList = ref.watch(animalListStreamProvider.last);
  // いいね！セット
  final futureLikeSet = ref.watch(likeSetStreamProvider.last);
  // 認証状態
  final futureAuth = ref.watch(authStreamProvider.last);
  // Futureで包まれているので、待つ。
  final animalList = await futureAnimalList;
  final likeSet = await futureLikeSet;
  final auth = await futureAuth;
  // リスト表示する項目を作成する
  final items = animalList
      .map((animal) => MainListItem(animal.name, likeSet.contains(animal.id)))
      .toList();
  return MainUiModel(items, !auth /* 認証していないならばログインボタンを表示 */);
});
```

# 画面を作成する

前節で紹介したProviderを使うWidgetの構築は、この記事の主題から外れコードが長いのでこちらにリンクを設置します。

[main.dart](https://github.com/tfandkusu/observe_firestore_with_auth/blob/main/lib/main.dart)

# 単体テスト

[前回の記事](https://qiita.com/tfandkusu/items/36640529294f65b6ae81#repository%E3%82%92%E3%83%A2%E3%83%83%E3%82%AF%E5%8C%96%E3%81%97%E3%81%A6presenter%E3%82%92%E3%83%86%E3%82%B9%E3%83%88%E3%81%99%E3%82%8B)同様に、StreamProviderをモック化して依存するProviderを単体テストすることができます。


```main_presenter_test.dart
void main() {
  // ログインしていない時のリスト表示を確認
  test('List animals without login', () async {
    final animalList = [
      Animal('1', 'alligator'),
      Animal('2', 'alpaca'),
      Animal('3', 'ape'),
      Animal('4', 'cat')
    ];
    final container = ProviderContainer(overrides: [
      animalListStreamProvider.overrideWithValue(AsyncValue.data(animalList)),
      authStreamProvider.overrideWithValue(AsyncValue.data(false)),
      userIdStreamProvider.overrideWithValue(AsyncValue.data(null))
    ]);
    final futureMainUiModel = container.read(mainUiModelProvider);
    final mainUiModel = await futureMainUiModel;
    // ログインボタンを表示する
    expect(mainUiModel.showAuth, true);
    // いいねはどの動物にもついていない
    expect(mainUiModel.items, [
      MainListItem('alligator', false),
      MainListItem('alpaca', false),
      MainListItem('ape', false),
      MainListItem('cat', false)
    ]);
  });

  // ログインしている時のリスト表示を確認
  test('List animals without login', () async {
    final animalList = [
      Animal('1', 'alligator'),
      Animal('2', 'alpaca'),
      Animal('3', 'ape'),
      Animal('4', 'cat')
    ];
    // idが2であるalpacaと4であるcatにいいねを付けている。
    final likeSet = Set.from(['2', '4']);
    final container = ProviderContainer(overrides: [
      animalListStreamProvider.overrideWithValue(AsyncValue.data(animalList)),
      likeSetStreamProvider.overrideWithValue(AsyncValue.data(likeSet)),
      authStreamProvider.overrideWithValue(AsyncValue.data(true))
    ]);
    final futureMainUiModel = container.read(mainUiModelProvider);
    final mainUiModel = await futureMainUiModel;
    // ログインボタンは表示しない
    expect(mainUiModel.showAuth, false);
    // alpacaとcatにいいねがついている
    expect(mainUiModel.items, [
      MainListItem('alligator', false),
      MainListItem('alpaca', true),
      MainListItem('ape', false),
      MainListItem('cat', true)
    ]);
  });
  // ログインしているときはログアウトボタンを表示する
  test('Show logout button', () async {
    final container = ProviderContainer(overrides: [
      authStreamProvider.overrideWithValue(AsyncValue.data(true))
    ]);
    final futureLogout = container.read(logoutProvider);
    final logout = await futureLogout;
    expect(logout, true);
  });
  // ログインしていない時はログアウトボタンを表示しない。
  test('Hide logout button', () async {
    final container = ProviderContainer(overrides: [
      authStreamProvider.overrideWithValue(AsyncValue.data(false))
    ]);
    final futureLogout = container.read(logoutProvider);
    final logout = await futureLogout;
    expect(logout, false);
  });
}
```