この記事は
先日書いた「RiverpodのStreamProviderをモック化して、依存するProviderを単体テストする」という記事で、RiverpodのStreamProviderでCloud Firestoreのリアルタイムアップデートを使う方法を解説しました。しかし、Firebase Authenticationと組み合わせて、Firestoreのユーザ別の領域にアクセスするケースに対応した方法の紹介までは行えていませんでした。
この記事ではFirestoreのデータとFirebase Authenticationの認証状態を両方とも監視して、リアルタイムに表示に反映させる方法を解説します。さらにデータと認証状態を監視しているStreamProviderをモック化して、それを使って表示の制御を行っているProviderの単体テストも行います。想定しているアーキテクチャは前回の記事のこちらをご参照ください。
サンプルアプリ
この記事で解説したい内容をサンプルアプリで説明します。
ある動物園が展示している動物一覧を提供しています(動物はkotlin-fakerで作成)。Sign in with GitHub
ボタンでGitHubにログインすると、その人が付けた「いいね!」がリスト表示に適用されます。右上のログアウトボタンを押すと、最初の表示に戻ります。
データ
動物一覧はFirestoreでこのようなデータになっています。
動物
動物はanimalsコレクションのドキュメントに格納します。ドキュメントにはnameフィールドがあります。
いいね!
いいね!はusersコレクションにUser IDと同名のドキュメントを作り、そこにlikesコレクションを作成し、いいね!した動物を参照するドキュメントを作成します。
animalIdフィールドがanimalsコレクションのドキュメントのIDです。
※ このスクリーンショットではUser IdをdummyUserとしています。
サンプルコード
今回の記事のサンプルコードです。Githubアカウントでログインできますが、簡単のため、全員ダミーユーザでログインした動作になります。
tfandkusu/observe_firestore_with_auth
参考文献
この記事とサンプルアプリは、こちらのイギリスのGoogle Developer Expertの人が作成したサンプルコードを参考にして作成しました。
bizz84/starter_architecture_flutter_firebase
StreamProviderを作成する
動物一覧のStreamProviderです。
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です。
/// 認証状態プロバイダー
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セットを流します。
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のおかげで分かりました。
表示を制御するためのProviderを作成する
Presenter層に相当するProviderの作り方は前回と同様です。
/// ログアウトボタン表示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の構築は、この記事の主題から外れコードが長いのでこちらにリンクを設置します。
単体テスト
前回の記事同様に、StreamProviderをモック化して依存するProviderを単体テストすることができます。
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);
});
}