🤩

【Flutter/dart】Chatを作ってみよう…③

2022/12/18に公開

はじめに

以下は、過去の記事で実装に必要な準備が記載されているのでご参照お願いします

https://zenn.dev/tomofyro/articles/2c2fad6941e333

https://zenn.dev/tomofyro/articles/b690f2c8d32056

とりあえず構成

以下のような構成でいこうと思う

  • チャット一覧(これをメインにする)
  • ユーザー登録画面
  • チャットルーム作成画面
  • チャット画面

メイン画面

一覧表示時に対象のデバイスのユーザー登録がない場合はユーザー登録画面を表示。
そうでない場合は一覧画面を表示。

とりあえず、チャットルームのサービスを準備

room_bloc.dart
class RoomState {
  final String name;
  final DateTime createdAt;

  const RoomState({
    required this.name,
    required this.createdAt,
  });

  factory RoomState.initState() {
    return RoomState(
      name: '',
      createdAt: DateTime.now(),
    );
  }
}
// Bloc State
class ListRoomState {
  final bool isLoading;
  final List<RoomState> roomList;

  const ListRoomState({
    required this.isLoading,
    required this.roomList,
  });

  factory ListRoomState.initState() {
    return const ListRoomState(
      isLoading: false,
      roomList: [],
    );
  }

  ListRoomState copyWith({
    bool? loading,
    List<RoomState>? list,
  }) {
    return ListRoomState(
      isLoading: loading ?? isLoading,
      roomList: list ?? roomList,
    );
  }
}

// Bloc Event
abstract class RoomBlocEvent {}

class StreamRoomEvent extends RoomBlocEvent {}

class MakeRoomEvent extends RoomBlocEvent {
  final String roomName;

  MakeRoomEvent({required this.roomName});
}

// Bloc
class RoomBloc extends Bloc<RoomBlocEvent, ListRoomState> {
  RoomBloc() : super(ListRoomState.initState()) {
    on<StreamRoomEvent>(_getRoomList);
    on<MakeRoomEvent>(_makeRoom);

    add(StreamRoomEvent());
  }

  Future<void> _getRoomList(
    StreamRoomEvent event,
    Emitter<ListRoomState> emit,
  ) async {
    emit(
      state.copyWith(
        loading: true,
      ),
    );

    await emit.onEach(
        FirebaseFirestore.instance
            .collection('chat_room')
            .orderBy('createdAt')
            .snapshots(), onData: (snapshot) {
      final List<RoomState> roomList = snapshot.docs.map(
        (d) {
          final data = d.data();
          return RoomState(
            name: data['name'],
            createdAt: (data['createdAt'] != null)
                ? DateTime.parse(data['createdAt'])
                : DateTime.now(),
          );
        },
      ).toList();

      emit(
        state.copyWith(
          loading: false,
          list: roomList,
        ),
      );
    });
  }

  Future<void> _makeRoom(
    MakeRoomEvent event,
    Emitter<ListRoomState> emit,
  ) async {
    final date = DateTime.now().toLocal().toIso8601String();

    await FirebaseFirestore.instance
        .collection('chat_room')
        .doc(event.roomName)
        .set({
      'name': event.roomName,
      'createdAt': date,
    });
  }
}

ユーザー登録・取得サービス

user_bloc.dart
// Bloc State
class UserState extends Equatable {
  final String seqId;
  final types.User user;
  final String uuid;

  const UserState({
    required this.seqId,
    required this.user,
    required this.uuid,
  });

  
  List<Object?> get props => [
        seqId,
        user,
        uuid,
      ];

  UserState copyWith({
    String? seqId,
    types.User? user,
    String? uuid,
  }) {
    return UserState(
      seqId: seqId ?? this.seqId,
      user: user ?? this.user,
      uuid: uuid ?? this.uuid,
    );
  }

  factory UserState.initState() {
    return UserState(
      seqId: const Uuid().v4(),
      user: const types.User(
        id: '',
        firstName: '',
        lastName: '',
      ),
      uuid: '',
    );
  }
}

// Bloc Event
abstract class UserEvent {}

class SetUserDataEvent extends UserEvent {
  final String firstName;
  final String lastName;
  final String image;

  SetUserDataEvent({
    required this.firstName,
    required this.lastName,
    required this.image,
  });
}

class GetUserDataEvent extends UserEvent {}

// Bloc
class UserBloc extends Bloc<UserEvent, UserState> {
  UserBloc() : super(UserState.initState()) {
    on<SetUserDataEvent>(_setUserData);
    on<GetUserDataEvent>(_getData);

    add(GetUserDataEvent());
  }

  Future<void> _setUserData(
    SetUserDataEvent event,
    Emitter<UserState> emit,
  ) async {
    final deviceId = await _getDeviceInfo();
    if (deviceId == '') {
      throw Exception('No device Id');
    }

    final seqId = const Uuid().v4();
    final userId = const Uuid().v4();
    await FirebaseFirestore.instance.collection('chat_users').add({
      'seqId': seqId,
      'userId': userId,
      'firstName': event.firstName,
      'lastName': event.lastName,
      'uuId': deviceId,
      'image': event.image,
      'createdAt': DateTime.now().millisecondsSinceEpoch,
    });

    emit(state.copyWith(
      seqId: seqId,
      user: types.User(
        id: userId,
        firstName: event.firstName,
        lastName: event.lastName,
      ),
      uuid: deviceId,
    ));
  }

  Future<void> _getData(
    GetUserDataEvent event,
    Emitter<UserState> emit,
  ) async {
    final deviceId = await _getDeviceInfo();
    final getData = await FirebaseFirestore.instance
        .collection('chat_users')
        .where('uuId', isEqualTo: deviceId)
        .get();
    if (getData.size > 0) {
      emit(state.copyWith(
        seqId: getData.docs.first.data()['seqId'],
        user: types.User(
          id: getData.docs.first.data()['userId'],
          firstName: getData.docs.first.data()['firstName'],
          lastName: getData.docs.first.data()['lastName'],
        ),
        uuid: getData.docs.first.data()['uuId'],
      ));
    }
  }

  Future<String> _getDeviceInfo() async {
    DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
    if (Platform.isAndroid) {
      final info = await deviceInfo.androidInfo;
      return info.id;
    } else if (Platform.isIOS) {
      final info = await deviceInfo.iosInfo;
      return info.identifierForVendor ?? '';
    }
    return "";
  }
}

メイン画面

room_page.dart
class RoomListPage extends StatelessWidget {
  const RoomListPage({super.key});

  
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider.value(value: UserBloc()),
        BlocProvider.value(value: RoomBloc()),
      ],
      child: BlocBuilder<UserBloc, UserState>(
        builder: (_, userState) {
          if (userState.uuid == '') {
            return const AddUser();
          }
          return Scaffold(
            appBar: AppBar(
              title: const Text('チャット一覧'),
            ),
            body: BlocBuilder<RoomBloc, ListRoomState>(
              builder: (_, state) {
                if (state.isLoading) {
                  return Expanded(
                    child: Center(
                      child: Column(
                        children: const [
                          CircularProgressIndicator(),
                          Text('読込中……'),
                        ],
                      ),
                    ),
                  );
                }
                return ListView.builder(
                  itemCount: state.roomList.length,
                  itemBuilder: (context, rowNum) {
                    final room = state.roomList[rowNum];
                    return Card(
                      child: ListTile(
                        title: Text(room.name),
                        trailing: IconButton(
                          icon: const Icon(Icons.input),
                          onPressed: () async {
                            await Navigator.of(context).push(
                              MaterialPageRoute(
                                builder: (context) {
                                  return ChatPage(
                                    name: room.name,
                                    user: userState.user,
                                  );
                                },
                              ),
                            );
                          },
                        ),
                      ),
                    );
                  },
                );
              },
            ),
            floatingActionButton: FloatingActionButton(
              child: const Icon(Icons.add),
              onPressed: () async {
                await Navigator.of(context)
                    .push(MaterialPageRoute(builder: (context) {
                  return const AddRoomPage();
                }));
              },
            ),
          );
        },
      ),
    );
  }
}

ユーザー作成画面

ユーザー作成画面。先ほど書いたuser_bloc.dartを使います。

add_user.dart
class AddUser extends StatefulWidget {
  const AddUser({super.key});

  
  State<AddUser> createState() => _AddUserState();
}

class _AddUserState extends State<AddUser> {
  var firstName = '';
  var lastName = '';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ユーザー作成'),
      ),
      body: Center(
        child: Container(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              TextFormField(
                decoration:
                    const InputDecoration(labelText: 'ユーザー名: firstName'),
                keyboardType: TextInputType.multiline,
                maxLines: 3,
                onChanged: (String name) {
                  setState(() {
                    firstName = name;
                  });
                },
              ),
              const SizedBox(
                height: 8,
              ),
              TextFormField(
                decoration: const InputDecoration(labelText: 'ユーザー名: lastName'),
                keyboardType: TextInputType.multiline,
                maxLines: 3,
                onChanged: (String name) {
                  setState(() {
                    lastName = name;
                  });
                },
              ),
              const SizedBox(
                height: 8,
              ),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  child: const Text('ユーザー作成'),
                  onPressed: () {
                    if (lastName != '' && firstName != '') {
                      context.read<UserBloc>().add(
                            SetUserDataEvent(
                              firstName: firstName,
                              lastName: lastName,
                              image: '',
                            ),
                          );
                    } else {
                      Fluttertoast.showToast(
                        msg: "ユーザー情報を埋めてください。",
                        toastLength: Toast.LENGTH_SHORT,
                        gravity: ToastGravity.CENTER,
                        timeInSecForIosWeb: 1,
                        backgroundColor: Colors.red,
                        textColor: Colors.white,
                        fontSize: 16.0,
                      );
                    }
                  },
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

チャットルーム作成

ユーザー作成画面。先ほど書いたroom_bloc.dartを使います。

add_room_page.dart
class AddRoomPage extends StatefulWidget {
  const AddRoomPage({super.key});

  
  State<AddRoomPage> createState() => _AddRoomPageState();
}

class _AddRoomPageState extends State<AddRoomPage> {
  String roomName = '';

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('ルーム作成'),
      ),
      body: Center(
        child: Container(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              TextFormField(
                decoration: const InputDecoration(labelText: 'チャットルーム名'),
                keyboardType: TextInputType.multiline,
                maxLines: 3,
                onChanged: (String value) {
                  setState(() {
                    roomName = value;
                  });
                },
              ),
              const SizedBox(
                height: 8,
              ),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  child: const Text('投稿'),
                  onPressed: () async {
                    RoomBloc().add(MakeRoomEvent(roomName: roomName));

                    if (!context.mounted) return;
                    Navigator.of(context).pop();
                  },
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

チャットルーム

Chatのサービス部分

chat_bloc.dart
// Bloc State
class MessageState extends Equatable {
  const MessageState({
    required this.title,
    required this.messages,
    required this.id,
  });
  final String title;
  final List<types.Message> messages;
  final String id;

  
  List<Object?> get props => [
        title,
        messages,
        id,
      ];

  MessageState copyWith({
    String? title,
    List<types.Message>? messages,
    String? id,
  }) {
    return MessageState(
      title: title ?? this.title,
      messages: messages ?? this.messages,
      id: id ?? this.id,
    );
  }

  factory MessageState.initState() {
    return const MessageState(
      title: '',
      messages: [],
      id: '',
    );
  }
}

// Bloc Event
abstract class MessageEvent {}

class MessageStreamEvent extends MessageEvent {}

class AddMessageEvent extends MessageEvent {
  AddMessageEvent({
    required this.message,
  });
  final types.TextMessage message;
}

// Bloc
class MessageBloc extends Bloc<MessageEvent, MessageState> {
  final String name;
  MessageBloc({required this.name}) : super(MessageState.initState()) {
    on<MessageStreamEvent>(_getMessage);
    on<AddMessageEvent>(_addMessage);

    add(MessageStreamEvent());
  }

  Future<void> _getMessage(
    MessageStreamEvent event,
    Emitter<MessageState> emit,
  ) async {
    await emit.onEach(
      FirebaseFirestore.instance
          .collection('chat_room')
          .doc(name)
          .collection('contents')
          .snapshots(),
      onData: (snapshot) {
        final messages = snapshot.docs
            .map(
              (d) => types.TextMessage(
                  author: types.User(
                      id: d.data()['uid'], firstName: d.data()['name']),
                  createdAt: d.data()['createdAt'],
                  id: d.data()['id'],
                  text: d.data()['text']),
            )
            .toList();
        messages.sort((a, b) => (b.createdAt ?? 0).compareTo(a.createdAt ?? 0));
        emit(
          state.copyWith(
            messages: messages,
          ),
        );
      },
    );
  }

  Future<void> _addMessage(
    AddMessageEvent event,
    Emitter<MessageState> emit,
  ) async {
    await FirebaseFirestore.instance
        .collection('chat_room')
        .doc(name)
        .collection('contents')
        .add({
      'uid': event.message.author.id,
      'name': event.message.author.firstName,
      'createdAt': event.message.createdAt,
      'id': event.message.id,
      'text': event.message.text,
    });
  }
}

Chatの画面

chat_page.dart
class ChatPage extends StatelessWidget {
  const ChatPage({
    super.key,
    required this.name,
    required this.user,
  });
  final String name;
  final types.User user;

  void _sendMessage(
    types.PartialText message,
    BuildContext context,
  ) {
    final String randomId = const Uuid().v4();
    final textMessage = types.TextMessage(
      author: user,
      createdAt: DateTime.now().millisecondsSinceEpoch,
      id: randomId,
      text: message.text,
    );

    context.read<MessageBloc>().add(AddMessageEvent(message: textMessage));
  }

  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => MessageBloc(
        name: name,
      ),
      child: Scaffold(
        appBar: AppBar(
          title: Text(name),
        ),
        body: BlocBuilder<MessageBloc, MessageState>(
          builder: (context, state) {
            return Chat(
              theme: const DefaultChatTheme(
                inputBackgroundColor: Colors.blue,
                sendButtonIcon: Icon(Icons.send),
                sendingIcon: Icon(Icons.update_outlined),
              ),
              showUserNames: true,
              messages: (state.isLoading) ? [] : state.messages,
              onSendPressed: (types.PartialText text) => _sendMessage(
                text,
                context,
              ),
              user: user,
            );
          },
        ),
      ),
    );
  }
}

ポイント

Bloc関連

MultBlocProviderとStream型に対してのBlocの書き方をわかりました。

  • MultBlocProviderについてはこれ

https://pub.dev/documentation/flutter_bloc/latest/flutter_bloc/MultiBlocProvider-class.html

  • Stream型に対してのBloc(リアルタイムでデータを取得)
 await emit.onEach(
   Stream,
   onData: (snapshot) {
     // ここでemitでデータを更新
   },
 );

FireBaseFireStore

  • DBのテーブル作成をバックエンドでしなくても、宣言したコレクションの中にデータを入れれること。(登録時にCreateテーブルもしてくれる)
  • FireBaseFireStoreの使い方を色々知ることができました。

https://www.flutter-study.dev/firebase/cloud-firestore-try

Flutter Chat UI

これはめっちゃ便利

https://pub.dev/packages/flutter_chat_ui

感想

バックエンドの知識がなくても、クラウドにデータを上げることできるのが画期的だなと今更ながら思った。
FireBaseは他にもPushやAuthentication,Crashlytics,Cloud Functionsなど色々なサービスがあるので最高だなと改めて感じた。(料金は知らない。。。)
また、入れたデータをGCPと連携することでデータ解析などもできるので。サービスの向上もやりやすそうです。
一度、FireBaseでシステム導入してコスト面で気になれば、自分でバックエンド構築してリファクタリングしてもいいかもなって思いました。

Discussion