Flutter Firebaseでチャットに既読機能をつける（後編）

## 前提
チャットのような既読機能ってどうやって実装するんだろう？
と思い手を動かしてみました。特に知見もないまま自力で実装したので、拙い部分もあると思います！
その際は、github経由でもいいのでIssueやコメントいただけると幸いです！
個人的に学習して知識がついたらこの記事もリファクタしたいと思います。

## 前回の記事

https://qiita.com/kurogoma939/items/0461a37b098e8bf28269
前回上記の記事で、チャットのルームに対して以下の様に通知をつける機能を実装しました。


<table>
    <tr>
        <td>チャット一覧（通知表示）</td>
        <td>チャット入力ページ</td>
    </tr>
    <tr>
        <td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/d18a986b-319b-41e0-97e3-3894a39665e9.png" width="250"></td>
        <td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/fff2d189-91b0-915a-3c93-c3951ca24c98.png" width="250"></td>
    </tr>
</table>

## 今回のゴール

今回の記事では、それよりパワーアップをさせ、以下のように通知数の表示とチャット単体の既読数管理を実装します

<table>
    <tr>
        <td>チャット一覧（通知数表示）</td>
        <td>チャット入力ページ（既読数表示）</td>
        <td>ログインページ</td>
    </tr>
    <tr>
        <td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/fcf87582-f987-8624-cfd8-99278453b9ac.png" width="250"></td>
        <td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/5a454721-34fb-5988-b74a-aa78b8befb14.png" width="250"></td>
        <td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/256d27f5-0c41-2868-16b0-700ac1045795.png" width="250"></td>
    </tr>
</table>

今回の記事は、以下の点が参考になればと思います。

- StreamBuilderとListView.builderを組み合わせたチャット風UI
- チャットアプリを実現するFirestoreの構成
- ルーム一覧に既読表示する方法
- チャット個別に既読数を表示する方法
- RiverPodを簡易的に使ってロジックを分離する方法

個人的に経験が浅い中で作成しているため、パフォーマンスやコード的に非推奨な部分もあるかと思います。
その際は、ご指摘いただけると励みになります。

https://github.com/kurogoma-flutter/chat_isRead_manage

## 使用環境
### バージョン
- Flutter 3.0.1
- Mac OS Monterey 12.4

### 使用パッケージ
```pubspec.yaml
dependencies:
  cloud_firestore: # Firestoreの処理を実装するため
  firebase_auth: # ログイン機能を実装するため
  firebase_core: # Firebaseを導入するため
  flutter:
    sdk: flutter
  flutter_riverpod: # RiverPodを使うため
  go_router: # 画面遷移
  uuid: # chatRoomのIDを生成するとき使いたかったが、今回未実装

dev_dependencies:
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter
  pedantic_mono: any # コードの悪いとこ指摘してくれてDartの書き方覚えられる
```

# 各機能の解説
## Firestoreの構成
コレクションは、`chatRoom`コレクションとそのサブコレクションとして
前回の記事では、以下のようにルームに既読があるかどうかを確認するだけであったため、chatRoomにreadUsersを持たせ、以下のような
ロジックで実装していました。
- contentコレクションが更新されたら、chatRoomの`readUses`を`readUsers['投稿者のUid']`とする
- チャット一覧では、`readUsers[]`に自身のUidなかった場合、未読表示をする
- リストからカードをタップし、チャットルームに入ったあと、StreabBuilderで正常に読み取りが完了したら既読処理を実行する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/35108514-8d12-6d62-24f3-19e6fd1aaca9.png" width="450" />

それに対し、今回はチャット単体に`readUsers[]`を持たせ、そこの情報を参照することで既読の有無など管理しています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/2efaae57-ef77-3571-bcfe-b8656226cf44.png" width="400" />

### コレクションについて
各コレクションについては、データの参照しやすさが上がればいいなと思い、以下のように想定しています。
※画像と名称が一部違いますが、下のテキストやこの後登場するコードが正です。

#### chatRoom
- roomName：ルームのタイトル名
- roomImageUrl：ルームにアイコンつけたい場合（未実装）
- roomId：ルーム作成時に`uuid`パッケージでdocumentIdを生成し、このroomIdにも持たせる（未実装）
- latestMessageAt：latestMessageの投稿日時を保存。うまく使うと`○時間前`など実装できる
- latestMessage：chatMessageコレクションで最後に投稿されたメッセージを保存
- createdAt：ルーム作成日時

#### chatMessage
- uid：投稿したユーザーのuid（ウィジェット切り替えに用いています）
- roomId：親に持たせていたroomIdを持たせる
- message：投稿メッセージ
- readUsers：既読したユーザー一覧と、既読数を管理するため
- createdAt：メッセージの投稿日時

## チャット一覧画面
今回、StreamBuilderを２カ所に用いてネストさせています。
- 全体のチャットリストの情報を取得するStream（`body`のところ）
- カードごと、未読数取得のためのStream（ListTileの`trailing`のところ）

```dart:chat_list_page.dart
class ChatListPage extends ConsumerWidget {
  const ChatListPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // viewModel定義
    final authViewModel = ref.read(authPageViewModelProvider);
    final chatViewModel = ref.watch(chatViewModelProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('チャットアプリの既読管理'),
        actions: [
          // サインアウト用ボタン
          IconButton(
            onPressed: () {
              authViewModel.signOut(context);
            },
            icon: const Icon(Icons.logout_outlined),
          ),
        ],
      ),
      body: StreamBuilder<QuerySnapshot>(
        // 監視対象。ここのクエリで取得できるデータに変化があった場合再描画される
        stream: chatViewModel.chatRoomListStream,
        builder: (context, snapshot) {
          // .connectionStateで接続状況の取得ができるので、これで色々切り替えても良い
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }
          // エラーが発生した時の処理（エラー画面遷移など）
          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }
          // Streamのクエリ条件でデータが0件だった場合
          if (!snapshot.hasData) {
            return const Center(child: Text('データが見つかりません'));
          }
          // データ表示
          return ListView(
            children: snapshot.data!.docs.map((DocumentSnapshot document) {
              // こうすることでdata['firestoreで設定したフィールドのキー']が使える
              final data = document.data()! as Map<String, dynamic>;

              return Card(
                child: ListTile(
                  leading: const CircleAvatar(),
                  title: Text('${data['roomName']}'),
                  subtitle: Text('${data['latestMessage']}'),
                  // 通知管理用にStreamBuilderをネストする
                  trailing: StreamBuilder<QuerySnapshot>(
                    // 未読状態の数を取得する
                    stream: chatViewModel.notReadChatStream(data),
                    builder: (
                      BuildContext context,
                      AsyncSnapshot<QuerySnapshot> snapshot,
                    ) {
                      if (snapshot.hasData) {
                        // Note: ここで扱う変数はListView単体向けの変数のため、こういう時にhooks使うと良さそう
                        final messageData = snapshot.data!.docs;
                        final notReadChatList = <String>[]; // 次画面に引き渡す未読チャットリスト
                        var readUserList = <dynamic>[]; // 既読ユーザーリスト
                        var notReadCount = 0; // 未読数カウント
                        // チャット一覧から未読未読ユーザーの取得
                        for (final message in messageData) {
                          notReadChatList.add(message.id);
                          readUserList = message['readUsers'] as List;
                          if (!readUserList.contains(
                            FirebaseAuth.instance.currentUser!.uid,
                          )) {
                            notReadCount++;
                          }
                        }
                        // 親のStreamに渡してしまう
                        data['notReadChatList'] = notReadChatList;
                        // 未読数を表示する数字のウィジェット
                        return NotReadChatCountWidget(dataCount: notReadCount);
                      }
                      // データが取得できなかった場合の仮置き
                      return const SizedBox();
                    },
                  ),
                  onTap: () {
                    // Todo: GoRouterに組み込みたいが、配列の受け渡しができない
                    chatViewModel.pushToChatDetailPage(context, data);
                  },
                ),
              );
            }).toList(),
          );
        },
      ),
    );
  }
}

/// 未読のチャット数を表示する
class NotReadChatCountWidget extends StatelessWidget {
  const NotReadChatCountWidget({
    super.key,
    required this.dataCount,
  });

  final int dataCount;

  @override
  Widget build(BuildContext context) {
    return Visibility(
      visible: dataCount > 0,
      child: CircleAvatar(
        maxRadius: 8,
        backgroundColor: Colors.pink,
        child: Text(
          dataCount.toString(),
          style: const TextStyle(
            color: Colors.white,
            fontSize: 12,
            fontWeight: FontWeight.w500,
          ),
        ),
      ),
    );
  }
}

```

#### 親のStream
```dart:chat_view_model.dart
Stream<QuerySnapshot> chatRoomListStream = FirebaseFirestore.instance
      .collection('chatRoom')
      .orderBy('createdAt')
      .snapshots();
```
#### 子のStream
```dart:chat_view_model.dart
Stream<QuerySnapshot> notReadChatStream(Map<String, dynamic> data) {
    return FirebaseFirestore.instance
        .collectionGroup('chatMessage')
        .where('roomId', isEqualTo: data['roomId'])
        .snapshots();
  }
```
> ※ サブコレクションの取得には、`.collectionGroup()`というクエリを用いると簡単にできますが、
少しFirestore側で設定が必要なので注意してください！

そもそもこの実装方法が正しいかはさておき、一番苦戦したのは子のStreamから親のStreamに値を渡したい場合です。
一旦、親の配列に値を渡すようにして受渡しています。
StreamBuilderの外でその値を使いたい！というときはどうすべきか。
そもそもこういう不恰好な部分を思うとこの実装方法はもしかしたら非推奨かもしれません。

```dart
// 親のStreamで取得したデータが格納される配列
final data = document.data()! as Map<String, dynamic>;

// 子のStreamで取得したデータが格納される配列
final messageData = snapshot.data!.docs;

// 未読チャットのdocumentIdのリストを受渡し
data['notReadChatList'] = notReadChatList;
```

#### 未読アイコンの表示
未読アイコンの表示には、Streamで取得してきた`readUsers`を調べ、自身のuidが含まれていなかった場合未読カウントを`+1`しています。また、未読アイコンの表示には、三項演算子ではなく、`Visibility()`ウィジェットを用いるのが良いかなと思います。

```dart:chat_list_page.dart
final messageData = snapshot.data!.docs;
final notReadChatList = <String>[]; // 次画面に引き渡す未読チャットリスト
var readUserList = <dynamic>[]; // 既読ユーザーリスト
var notReadCount = 0; // 未読数カウント
// チャット一覧から未読未読ユーザーの取得
for (final message in messageData) {
  notReadChatList.add(message.id);
  readUserList = message['readUsers'] as List;
  if (!readUserList.contains(FirebaseAuth.instance.currentUser!.uid,)) {
    notReadCount++;
  }
}
data['notReadChatList'] = notReadChatList;
// 未読数を表示する数字のウィジェット
return NotReadChatCountWidget(dataCount: notReadCount);

// ...........
/// 未読のチャット数を表示する
class NotReadChatCountWidget extends StatelessWidget {
  const NotReadChatCountWidget({
    super.key,
    required this.dataCount,
  });

  final int dataCount;

  @override
  Widget build(BuildContext context) {
    return Visibility(
      visible: dataCount > 0,
      child: CircleAvatar(
        maxRadius: 8,
        backgroundColor: Colors.pink,
        child: Text(
          dataCount.toString(),
          style: const TextStyle(
            color: Colors.white,
            fontSize: 12,
            fontWeight: FontWeight.w500,
          ),
        ),
      ),
    );
  }
}
```

## チャット詳細画

次に、チャット詳細画面です。こちらは至ってシンプルです。StreamBuilderとListView.builderの組み合わせ方だけわかれば苦戦することは少ないかなと思います。

主に以下の実装について解説して行きます。

- `chat_list_page.dart`から`roomId`と`roomName`を受け取る
- Streamを取得し、`chatMessage`コレクションを表示する
- 既読処理をする（あなたが既読する）
- `readUsres`から既読数を算出する
- チャット投稿処理（chatMessageとchatRoomの更新）

```dart:chat_detail_page.dart
class ChatDetailPage extends ConsumerStatefulWidget {
  const ChatDetailPage({
    super.key,
    required this.notReadChatList,
    required this.roomId,
    required this.roomName,
  });
  final List<String> notReadChatList;
  final String roomId;
  final String roomName;

  @override
  ConsumerState<ChatDetailPage> createState() => _ChatDetailPageState();
}

class _ChatDetailPageState extends ConsumerState<ChatDetailPage> {
  @override
  Widget build(BuildContext context) {
    final chatViewModel = ref.watch(chatViewModelProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.roomName),
      ),
      body: StreamBuilder<QuerySnapshot>(
        stream: chatViewModel.chatMessageStream(widget.roomId),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          }
          if (!snapshot.hasData) {
            return const Center(child: Text('データが見つかりません'));
          }
          // 既読処理
          chatViewModel.readChatMessage(
            widget.notReadChatList,
            widget.roomId,
          );
          // データ表示
          final chatItemList = snapshot.data!.docs;
          return Column(
            children: <Widget>[
              Expanded(
                child: Padding(
                  padding: const EdgeInsets.only(top: 10),
                  child: ListView.builder(
                    shrinkWrap: true,
                    reverse: true,
                    // controller: _scrollController,
                    itemCount: chatItemList.length,
                    itemBuilder: (BuildContext context, int index) {
                      final data = chatItemList;
                      // ignore: unrelated_type_equality_checks
                      return data[index]['uid'] ==
                              FirebaseAuth.instance.currentUser!.uid
                          ? RightBalloon(
                              content: data[index]['message'] as String,
                              readUsers:
                                  data[index]['readUsers'] as List<dynamic>,
                            )
                          : LeftBalloon(
                              content: data[index]['message'] as String,
                            );
                    },
                  ),
                ),
              ),
              TextInputWidget(roomId: widget.roomId),
            ],
          );
        },
      ),
    );
  }
}
```
既読管理は、チャットの吹き出しの中にあります。
`RightBaloon`という自作のウィジェットに組んでいるのでそちらをみて行きます。
とはいえ、表示ロジックは一覧と変わりません。
チャット投稿ロジックの説明は後で解説をしますが、`readUsers`は、チャット投稿時に投稿者のuidは格納済みの状態です。
そのため、`readUsers.length - 1（自分の分）`を既読したユーザー数として扱っています。

```dart
final readUserCount = readUsers.length - 1;
```

```lib/presentation/components/chat_item.dart
/// チャット吹き出し（自分側）
class RightBalloon extends StatelessWidget {
  const RightBalloon({
    super.key,
    required this.content,
    required this.readUsers,
  });
  final String content;
  final List<dynamic> readUsers;
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 20, right: 10),
      child: Row(
        children: [
          const Spacer(),
          Consumer(
            builder: (context, ref, child) {
              final readUserCount = readUsers.length - 1;
              return Padding(
                padding: const EdgeInsets.only(top: 30, right: 8),
                child: Visibility(
                  visible: readUserCount > 0,
                  child: Text(
                    readUserCount.toString(),
                    style: const TextStyle(
                      color: Colors.deepOrangeAccent,
                      fontWeight: FontWeight.w500,
                    ),
                  ),
                ),
              );
            },
          ),
          DecoratedBox(
            decoration: const BoxDecoration(
              borderRadius: BorderRadius.only(
                topRight: Radius.circular(40),
                topLeft: Radius.circular(40),
                bottomLeft: Radius.circular(40),
              ),
              gradient: LinearGradient(
                begin: FractionalOffset.topLeft,
                end: FractionalOffset.bottomRight,
                colors: [
                  Color.fromARGB(255, 47, 137, 233),
                  Color.fromARGB(255, 105, 79, 248),
                ],
                stops: [0.0, 1.0],
              ),
            ),
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                content,
                style: const TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
```

以上です！

Githubのリンクを以下に貼っておくので、多少参考になればと思います。
最後まで読んでいただきありがとうございました。

ぜひフォローやいいねお願いします！

https://github.com/kurogoma-flutter/chat_isRead_manage