Flutter Firebaseでチャットアプリに簡易既読機能を実装する（前半）

## 今回のゴール
チャット形式のアプリにおいて、簡易的に既読機能を実装する

既読機能と言えば、チャットルームにつけるものと、チャットコメント単体に対してつけるものがありますが、今回はルームに対して既読機能をつけます。（LINEよりかChatWork的な）

## Firestoreの構成
![スクリーンショット 2022-06-06 15.54.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/6e8607ba-ba9e-97d3-f933-198f7ba4205a.png)
### chatRoomコレクション
#### フィールドの想定
※ 今回のサンプルはかなりサンプルのため、一部実装できていない部分もあります。
- `roomId`：chatRoomのdocumentIdと一致（uuidパッケージで自動生成）
- `createdAt`：登録日時
- l`astMessage`：contentコレクションで最後に呟かれたメッセージテキストを保存し、SNSであるあるな最新メッセージを表示する
- `lastMessageAt`：lastMessageの投稿日時。今回は使用していないが、上手く用いると「〜分前」などが実装可能
- `readUsers`：配列で既読したユーザーのuidのリストを保管する。一覧表示の際、自身のuidが無ければ未読マークを付けるようにする


### contentコレクション
#### フィールドの想定
- `uid`：投稿したユーザーのUid。このUidが自身のuidと一致するかどうかによってウィジェットを切り替えれば対話風のUIが作れる
- `text`：投稿したテキスト
- `roomId`：chatRoomとの紐付け用
- `createdAt`：登録日時

## 画面イメージと処理概要
### 画面イメージ
<table>
<tr>
<td>
一覧ページ（chatRoom）
</td>
<td>
チャットページ（content）
</td>
</tr>
<tr>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/82bf0613-6461-64fc-b62e-f92a5dce853c.png" /></td>
<td><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/a588dbed-8397-a78b-010c-15dd104592e8.png" /></td>
</tr>
</table>

### 処理概要
#### 一覧ページ
- chatRoomから一覧取得し、streamBuilderで描画
- ListTileをタップしたら既読をつけ、チャットページに遷移

#### チャットページ
- チャットテキストを投稿したら、chatRoomの`last〜`と`readUsers`を更新する


## 主要部分のコード解説
※全体は最後にGithubのリンクを添付するのでそちらで確認お願いします。
```dart:main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  GoRouter.setUrlPathStrategy(UrlPathStrategy.path);
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  MyApp({Key? key}) : super(key: key);

  final GoRouter router = GoRouter(
    routes: <GoRoute>[
      GoRoute(
        path: '/', // ベース：認証状態を識別してチャットリスト画面orログインへ遷移させる
        builder: (BuildContext context, GoRouterState state) =>
            const HomePage(),
      ),
      GoRoute(
        path: '/chatList', 
        builder: (BuildContext context, GoRouterState state) =>
            const ChatListPage(),
      ),
      GoRoute(
        path: '/room/:roomId', // チャットルーム
        builder: (context, state) {
          // パスパラメータの値を取得するには state.params を使用
          final String roomId = state.params['roomId']!;
          return ChatPage(roomId: roomId);
        },
      ),
      GoRoute(
        path: '/login', // ログイン画面
        builder: (BuildContext context, GoRouterState state) =>
            const LoginPage(),
      ),
    ],
    initialLocation: '/',
  );

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: router.routeInformationParser,
      routerDelegate: router.routerDelegate,
      title: 'レビューアプリ',
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
        stream: FirebaseAuth.instance.authStateChanges(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
          if (snapshot.hasData) {
            return const ChatListPage();
          } else {
            return const LoginPage();
          }
        });
  }
}
```

### 既読の丸いポツの表示
```dart:chat_list_page.dart
  /// Streamの配列に値があるか確認し、true, falseを返す
  bool _isRead(List readUsers) {
    User? user = FirebaseAuth.instance.currentUser;
    if (readUsers.contains(user!.uid)) {
      return false;
    }
    return true;
  }

child: ListTile(
    leading: const CircleAvatar(),
    title: Text('${data['roomId']}'),
    subtitle: Text('${data['lastMessage']}'),
        trailing: Visibility(
            visible: _isRead(data['readUsers']),
                child: const CircleAvatar(
                     maxRadius: 4,
                     backgroundColor: Colors.blue,
                ),
            ),
    onTap: () async {
        await readAction(data['roomId'], data['readUsers']);
                      context.go('/room/${data['roomId']}');
    },
),
```

表示の切り替えについては、`Visibility()`ウィジェットがおすすめです。さらに、非表示の場合は空の領域を確保するか否かも選択できるのでとても使い勝手がいいかなと思います。

### 既読処理
Firestoreのメソッドで、対象フィールドだけ更新したい場合はupdateを用いてください。

```dart:chat_list_page.dart
  // 引数はどちらもStreamBuilderで取得できている
  readAction(String roomId, List readUsers) {
    String uid = FirebaseAuth.instance.currentUser!.uid;
    // 重複登録を避け、登録がなければ配列に追加
    if (!readUsers.contains(uid)) {
      readUsers.add(uid);
    }

    FirebaseFirestore.instance
        .collection('chatRoom')
        .doc(roomId)
        .update({'readUsers': readUsers});
  }
```

### 投稿処理
投稿時、省略してしまいましたがcontentコレクションの更新と、以下のようにchatRoomの更新をするようにしてください。

```dart:chat_page.dart
  Future<void> postMessage() async {
    // 投稿者が未読はおかしいのであらかじめセットする
    List<String> initReadUser = [FirebaseAuth.instance.currentUser!.uid];

    // 更新処理
    if (_chatText.isNotEmpty) {
      await FirebaseFirestore.instance
          .collection('chatRoom')
          .doc(widget.roomId)
          .update({
        'readUsers': initReadUser,
        'lastMessage': _chatText,
        'lastMessageAt': Timestamp.now()
      });

      _textEditingController.clear();
      _chatText = '';
    }
  }
```

以上です。
今回の記事の[Githubはこちら](https://github.com/kurogoma-flutter/chat_read_user_app)です。

最後まで読んでいただきありがとうございました。