14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

FlutterとSupabaseでチャットアプリを作る - その2・セキュリティ編

Last updated at Posted at 2022-11-30

SupabaseでDevRelしてます、タイラーです!今回もSupabaseとFlutterを使ってチャットアプリを作っていきます。

今回はこちらの記事の第二弾ということで、前回作ったシンプルなチャットアプリにきちんとセキュリティ周りの設定を施してきちんと1対1のセキュアなメッセージのやり取りができるアプリにしていきたいと思います。まだ第一弾を読まれていない方はぜひそちらを読んでから戻ってきてください!

一応前回のおさらい

本編に入る前に簡単に前回何を作ったかをおさらいしましょう。前回の記事ではメールアドレスとパスワードで登録・ログインをして、それが完了したらチャットルームに入るアプリを作りました。チャットルームはアプリ共通で一つしかなく、全員が同じチャットルームにみんなに見える形でチャットを書き込む形でした。

Chat app without authorization

完成系の概要

今回作るアプリは前回のアプリに「部屋」という概念を用意してあげて、1対1のプライベートなチャットのやり取りができるようにしていきます。アプリにログインすると左側に見える「部屋一覧」ページに入ります。この部屋一覧ページは二つ役割があります。画面上部に横に並んでいるアイコンとユーザー名はこのアプリの新規ユーザーが並んでいて、タップするとそのユーザーとチャットを開始するために右のチャット画面に入ります。部屋一覧画面の下に縦に並んでいるのは過去にチャットしたユーザー一覧とそのチャットの最新のメッセージで、これらをタップした場合も該当のチャット画面に入ります。

Chat app without authorization

こちらに完成したアプリのコードも置いておきますのでよろしければ見てみてください。

事前準備

パッケージのインストール

今回のアプリは少し複雑にデータのやり取りをする必要があるので状態管理ライブラリーの手を借りたいと思います。どの状態管理ライブラリーを使ってもよかったのですが、今回はflutter_blocを使います。こちらをpubspec.yamlにコピペしてインストールしちゃいましょう!

flutter_bloc: ^8.0.0

データベースのスキーマの変更

アプリに新しく部屋機能をつけるにあたってデータベースのスキーマの定義も変更する必要があります。具体的には、まず部屋一覧を保持するroomsテーブルを作る必要があるのと、あとはチャット一覧を保持するmessagesにどのメッセージがどの部屋に対して送られたものなのかの情報を持つ必要があります。加えてどの部屋にどのユーザーが参加しているのかを保持するテーブルも作ってあげましょう。

ここでポイントなのがcreate_new_roomというDatabaase Function。こちらはユーザーが他のユーザーと新しくチャットを始めようとしたときに呼ばれるものなのですが、結構良くできていて、タップしたユーザーと一緒に入っている部屋がないときは新しく部屋を作成し部屋IDを返してくれて、すでに部屋がある場合は何もせずに該当する部屋IDだけ返してくれます。フロントエンドではこの関数を呼んで、該当する部屋に入れば良いだけという実装でチャットの部屋機能が実装できます!

-- *** Table definitions ***

create table if not exists public.rooms (
    id uuid not null primary key default uuid_generate_v4(),
    created_at timestamp with time zone default timezone('utc' :: text, now()) not null
);
comment on table public.rooms is 'Holds chat rooms';

create table if not exists public.room_participants (
    profile_id uuid references public.profiles(id) on delete cascade not null,
    room_id uuid references public.rooms(id) on delete cascade not null,
    created_at timestamp with time zone default timezone('utc' :: text, now()) not null,
    primary key (profile_id, room_id)
);
comment on table public.room_participants is 'Relational table of users and rooms.';

alter table public.messages
add column room_id uuid references public.rooms(id) on delete cascade not null;

-- *** Add tables to the publication to enable realtime ***

alter publication supabase_realtime add table public.room_participants;

-- Creates a new room with the user and another user in it.
-- Will return the room_id of the created room
-- Will return a room_id if there were already a room with those participants
create or replace function create_new_room(other_user_id uuid) returns uuid as $$
    declare
        new_room_id uuid;
    begin
        -- Check if room with both participants already exist
        with rooms_with_profiles as (
            select room_id, array_agg(profile_id) as participants
            from room_participants
            group by room_id               
        )
        select room_id
        into new_room_id
        from rooms_with_profiles
        where create_new_room.other_user_id=any(participants)
        and auth.uid()=any(participants);


        if not found then
            -- Create a new room
            insert into public.rooms default values
            returning id into new_room_id;

            -- Insert the caller user into the new room
            insert into public.room_participants (profile_id, room_id)
            values (auth.uid(), new_room_id);

            -- Insert the other_user user into the new room
            insert into public.room_participants (profile_id, room_id)
            values (other_user_id, new_room_id);
        end if;

        return new_room_id;
    end
$$ language plpgsql security definer;

DeepLinkの設定

前回のアプリでは一旦セキュリティは放っておいていたのでスルーしましたが、今回のアプリはちゃんとセキュアなアプリを作るということで、ユーザーがアプリに登録した際にきちんと認証メールが届くようにして、その認証メールをクリックしないとアプリにログインできないようにしてあげましょう。

SupabaseではデフォルトでEmailでログインを実装したときに認証メールが届くような形になっているのですが、この認証メールのリンクをクリックした際にユーザーが自動的にアプリに戻ってくるようにしたいです。このために、supabase_flutterではDeeplinkを使っています。以下の設定を行うだけで簡単に認証メールのリンクをクリックした際に自動的にユーザーをアプリに引き戻してあげるようにすることができるので設定していきましょう。

iOSではInfo.plistを編集します。

xml title=ios/Runner/Info.plist"
<!-- ... other tags -->
<plist>
<dict>
  <!-- ... other tags -->

  <!-- Add this array for Deep Links -->
  <key>CFBundleURLTypes</key>
  <array>
    <dict>
      <key>CFBundleTypeRole</key>
      <string>Editor</string>
      <key>CFBundleURLSchemes</key>
      <array>
        <string>io.supabase.chat</string>
      </array>
    </dict>
  </array>
  <!-- ... other tags -->
</dict>
</plist>

AndroidではAndroidManifest.xmlを編集します。

xml title=android/app/src/main/AndroidManifest.xml
<manifest ...>
  <!-- ... other tags -->
  <application ...>
    <activity ...>
      <!-- ... other tags -->

      <!-- Add this intent-filter for Deep Links -->
      <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
        <data
          android:scheme="io.supabase.chat"
          android:host="login" />
      </intent-filter>

    </activity>
  </application>
</manifest>

最後にSupabaseのダッシュボード側でもDeeplinkの設定をしてあげましょう。ダッシュボードのAuthentication > URL Configurationに入ってio.supabase.chat://loginをRedirect Urlsに追加してあげましょう。

Deep link URL Configuration

これで下準備は完了です!実際のFlutterのアプリ開発に入っていきましょう!

アプリケーションの構築

Step1: 部屋一覧ページの作成

部屋一覧ページは2種類のデータをロードしています。まず上に表示するための新規ユーザー一覧と、ログインしているユーザーの過去のチャット履歴一覧です。

まずはこの部屋一覧ページのStateを作っていきましょう。
lib/cubit/rooms/rooms_state.dartファイルを作って、部屋一覧ページが取りうる状態を定義してあげます。

dart title=lib/cubit/rooms/rooms_state.dart
part of 'rooms_cubit.dart';

@immutable
abstract class RoomState {}

class RoomsLoading extends RoomState {}

class RoomsLoaded extends RoomState {
  final List<Profile> newUsers;
  final List<Room> rooms;

  RoomsLoaded({
    required this.rooms,
    required this.newUsers,
  });
}

class RoomsEmpty extends RoomState {
  final List<Profile> newUsers;

  RoomsEmpty({required this.newUsers});
}

class RoomsError extends RoomState {
  final String message;

  RoomsError(this.message);
}

状態の定義ができたら、今度はCubitを作ってあげます。Cubitとはflutter_bloc内で提供されているクラスで、サーバーとのデータのやり取りを行なってくれてそれをUIで使うためのStateに変換してくれるクラスです。

ここでは、supabase_flutter.stream()機能を使ってこのユーザーの入っている部屋一覧やそこの最新のチャット情報などをリアルタイムに取得しています。

dart title=lib/cubit/rooms/rooms_cubit.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';
import 'package:my_chat_app/models/profile.dart';
import 'package:my_chat_app/models/message.dart';
import 'package:my_chat_app/models/room.dart';
import 'package:my_chat_app/utils/constants.dart';

part 'rooms_state.dart';

class RoomCubit extends Cubit<RoomState> {
  RoomCubit() : super(RoomsLoading());

  final Map<String, StreamSubscription<Message?>>
      _messageSubscriptions = {};

  late final String _myUserId;

  /// List of new users of the app for the user to start talking to
  late final List<Profile> _newUsers;

  /// List of rooms
  List<Room> _rooms = [];
  StreamSubscription<List<Map<String, dynamic>>>?
      _rawRoomsSubscription;
  bool _haveCalledGetRooms = false;

  Future<void> initializeRooms(BuildContext context) async {
    if (_haveCalledGetRooms) {
      return;
    }
    _haveCalledGetRooms = true;

    _myUserId = supabase.auth.currentUser!.id;

    late final List data;

    try {
      data = await supabase
          .from('profiles')
          .select()
          .not('id', 'eq', _myUserId)
          .order('created_at')
          .limit(12);
    } catch (_) {
      emit(RoomsError('Error loading new users'));
    }

    final rows = List<Map<String, dynamic>>.from(data);
    _newUsers = rows.map(Profile.fromMap).toList();

    /// Get realtime updates on rooms that the user is in
    _rawRoomsSubscription =
        supabase.from('room_participants').stream(
      primaryKey: ['room_id', 'profile_id'],
    ).listen((participantMaps) async {
      if (participantMaps.isEmpty) {
        emit(RoomsEmpty(newUsers: _newUsers));
        return;
      }

      _rooms = participantMaps
          .map(Room.fromRoomParticipants)
          .where((room) => room.otherUserId != _myUserId)
          .toList();
      for (final room in _rooms) {
        _getNewestMessage(
            context: context, roomId: room.id);
        BlocProvider.of<ProfilesCubit>(context)
            .getProfile(room.otherUserId);
      }
      emit(RoomsLoaded(
        newUsers: _newUsers,
        rooms: _rooms,
      ));
    }, onError: (error) {
      emit(RoomsError('Error loading rooms'));
    });
  }

  // Setup listeners to listen to the most recent message in each room
  void _getNewestMessage({
    required BuildContext context,
    required String roomId,
  }) {
    _messageSubscriptions[roomId] = supabase
        .from('messages')
        .stream(primaryKey: ['id'])
        .eq('room_id', roomId)
        .order('created_at')
        .limit(1)
        .map<Message?>(
          (data) => data.isEmpty
              ? null
              : Message.fromMap(
                  map: data.first,
                  myUserId: _myUserId,
                ),
        )
        .listen((message) {
          final index = _rooms
              .indexWhere((room) => room.id == roomId);
          _rooms[index] =
              _rooms[index].copyWith(lastMessage: message);
          _rooms.sort((a, b) {
            /// Sort according to the last message
            /// Use the room createdAt when last message is not available
            final aTimeStamp = a.lastMessage != null
                ? a.lastMessage!.createdAt
                : a.createdAt;
            final bTimeStamp = b.lastMessage != null
                ? b.lastMessage!.createdAt
                : b.createdAt;
            return bTimeStamp.compareTo(aTimeStamp);
          });
          if (!isClosed) {
            emit(RoomsLoaded(
              newUsers: _newUsers,
              rooms: _rooms,
            ));
          }
        });
  }

  /// Creates or returns an existing roomID of both participants
  Future<String> createRoom(String otherUserId) async {
    final data = await supabase.rpc('create_new_room',
        params: {'other_user_id': otherUserId});
    emit(RoomsLoaded(rooms: _rooms, newUsers: _newUsers));
    return data as String;
  }

  @override
  Future<void> close() {
    _rawRoomsSubscription?.cancel();
    return super.close();
  }
}

部屋一覧ページのStateとCubitが完成したらあとはページの作成に入るだけです!このページはシンプルに横スクロールのListViewと縦スクロールのListViewの組み合わせになっています。

dart title=lib/pages/rooms_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';

import 'package:my_chat_app/cubits/rooms/rooms_cubit.dart';
import 'package:my_chat_app/models/profile.dart';
import 'package:my_chat_app/pages/chat_page.dart';
import 'package:my_chat_app/pages/register_page.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:timeago/timeago.dart';

/// Displays the list of chat threads
class RoomsPage extends StatelessWidget {
  const RoomsPage({Key? key}) : super(key: key);

  static Route<void> route() {
    return MaterialPageRoute(
      builder: (context) => BlocProvider<RoomCubit>(
        create: (context) =>
            RoomCubit()..initializeRooms(context),
        child: const RoomsPage(),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Rooms'),
        actions: [
          TextButton(
            onPressed: () async {
              await supabase.auth.signOut();
              Navigator.of(context).pushAndRemoveUntil(
                RegisterPage.route(),
                (route) => false,
              );
            },
            child: const Text('Logout'),
          ),
        ],
      ),
      body: BlocBuilder<RoomCubit, RoomState>(
        builder: (context, state) {
          if (state is RoomsLoading) {
            return preloader;
          } else if (state is RoomsLoaded) {
            final newUsers = state.newUsers;
            final rooms = state.rooms;
            return BlocBuilder<ProfilesCubit,
                ProfilesState>(
              builder: (context, state) {
                if (state is ProfilesLoaded) {
                  final profiles = state.profiles;
                  return Column(
                    children: [
                      _NewUsers(newUsers: newUsers),
                      Expanded(
                        child: ListView.builder(
                          itemCount: rooms.length,
                          itemBuilder: (context, index) {
                            final room = rooms[index];
                            final otherUser =
                                profiles[room.otherUserId];

                            return ListTile(
                              onTap: () =>
                                  Navigator.of(context)
                                      .push(ChatPage.route(
                                          room.id)),
                              leading: CircleAvatar(
                                child: otherUser == null
                                    ? preloader
                                    : Text(otherUser
                                        .username
                                        .substring(0, 2)),
                              ),
                              title: Text(otherUser == null
                                  ? 'Loading...'
                                  : otherUser.username),
                              subtitle: room.lastMessage !=
                                      null
                                  ? Text(
                                      room.lastMessage!
                                          .content,
                                      maxLines: 1,
                                      overflow: TextOverflow
                                          .ellipsis,
                                    )
                                  : const Text(
                                      'Room created'),
                              trailing: Text(format(
                                  room.lastMessage
                                          ?.createdAt ??
                                      room.createdAt,
                                  locale: 'en_short')),
                            );
                          },
                        ),
                      ),
                    ],
                  );
                } else {
                  return preloader;
                }
              },
            );
          } else if (state is RoomsEmpty) {
            final newUsers = state.newUsers;
            return Column(
              children: [
                _NewUsers(newUsers: newUsers),
                const Expanded(
                  child: Center(
                    child: Text(
                        'Start a chat by tapping on available users'),
                  ),
                ),
              ],
            );
          } else if (state is RoomsError) {
            return Center(child: Text(state.message));
          }
          throw UnimplementedError();
        },
      ),
    );
  }
}

class _NewUsers extends StatelessWidget {
  const _NewUsers({
    Key? key,
    required this.newUsers,
  }) : super(key: key);

  final List<Profile> newUsers;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.symmetric(vertical: 8),
      scrollDirection: Axis.horizontal,
      child: Row(
        children: newUsers
            .map<Widget>((user) => InkWell(
                  onTap: () async {
                    try {
                      final roomId =
                          await BlocProvider.of<RoomCubit>(
                                  context)
                              .createRoom(user.id);
                      Navigator.of(context)
                          .push(ChatPage.route(roomId));
                    } catch (_) {
                      context.showErrorSnackBar(
                          message:
                              'Failed creating a new room');
                    }
                  },
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: SizedBox(
                      width: 60,
                      child: Column(
                        children: [
                          CircleAvatar(
                            child: Text(user.username
                                .substring(0, 2)),
                          ),
                          const SizedBox(height: 8),
                          Text(
                            user.username,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                          ),
                        ],
                      ),
                    ),
                  ),
                ))
            .toList(),
      ),
    );
  }
}

まだ他のページを作っていないので要所要所でエラーが見受けられますが、気にせずアプリの残りを作っていきましょう。

Step 2: チャットページをアップデート

前回のアプリでもチャットページは作ったのですが、今回はこちらにflutter_blocを入れてあげて綺麗に状態管理できるようにしてあげましょう。まずはStateの定義から。

dart title=lib/cubits/chat/chat_state.dart
part of 'chat_cubit.dart';

@immutable
abstract class ChatState {}

class ChatInitial extends ChatState {}

class ChatLoaded extends ChatState {
  ChatLoaded(this.messages);
  final List<Message> messages;
}

class ChatEmpty extends ChatState {}

class ChatError extends ChatState {
  ChatError(this.message);
  final String message;
}

そしてチャットをロードしてくるCubitの定義。

dart title=lib/cubits/chat/chat_cubit.dart
import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:my_chat_app/models/message.dart';
import 'package:my_chat_app/utils/constants.dart';

part 'chat_state.dart';

class ChatCubit extends Cubit<ChatState> {
  ChatCubit() : super(ChatInitial());

  StreamSubscription<List<Message>>? _messagesSubscription;
  List<Message> _messages = [];

  late final String _roomId;
  late final String _myUserId;

  void setMessagesListener(String roomId) {
    _roomId = roomId;

    _myUserId = supabase.auth.currentUser!.id;

    _messagesSubscription = supabase
        .from('messages')
        .stream(primaryKey: ['id'])
        .eq('room_id', roomId)
        .order('created_at')
        .map<List<Message>>(
          (data) => data
              .map<Message>(
                  (row) => Message.fromMap(map: row, myUserId: _myUserId))
              .toList(),
        )
        .listen((messages) {
          _messages = messages;
          if (_messages.isEmpty) {
            emit(ChatEmpty());
          } else {
            emit(ChatLoaded(_messages));
          }
        });
  }

  Future<void> sendMessage(String text) async {
    /// Add message to present to the user right away
    final message = Message(
      id: 'new',
      roomId: _roomId,
      profileId: _myUserId,
      content: text,
      createdAt: DateTime.now(),
      isMine: true,
    );
    _messages.insert(0, message);
    emit(ChatLoaded(_messages));

    try {
      await supabase.from('messages').insert(message.toMap());
    } catch (_) {
      emit(ChatError('Error submitting message.'));
      _messages.removeWhere((message) => message.id == 'new');
      emit(ChatLoaded(_messages));
    }
  }

  @override
  Future<void> close() {
    _messagesSubscription?.cancel();
    return super.close();
  }
}

最後にメインのチャットページもCubitからデータをロードしてあげるようにしてあげましょう。

dart title=lib/pages/chat_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_chat_app/components/user_avatar.dart';
import 'package:my_chat_app/cubits/chat/chat_cubit.dart';

import 'package:my_chat_app/models/message.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:timeago/timeago.dart';

/// Page to chat with someone.
///
/// Displays chat bubbles as a ListView and TextField to enter new chat.
class ChatPage extends StatelessWidget {
  const ChatPage({Key? key}) : super(key: key);

  static Route<void> route(String roomId) {
    return MaterialPageRoute(
      builder: (context) => BlocProvider<ChatCubit>(
        create: (context) => ChatCubit()..setMessagesListener(roomId),
        child: const ChatPage(),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Chat')),
      body: BlocConsumer<ChatCubit, ChatState>(
        listener: (context, state) {
          if (state is ChatError) {
            context.showErrorSnackBar(message: state.message);
          }
        },
        builder: (context, state) {
          if (state is ChatInitial) {
            return preloader;
          } else if (state is ChatLoaded) {
            final messages = state.messages;
            return Column(
              children: [
                Expanded(
                  child: ListView.builder(
                    padding: const EdgeInsets.symmetric(vertical: 8),
                    reverse: true,
                    itemCount: messages.length,
                    itemBuilder: (context, index) {
                      final message = messages[index];
                      return _ChatBubble(message: message);
                    },
                  ),
                ),
                const _MessageBar(),
              ],
            );
          } else if (state is ChatEmpty) {
            return Column(
              children: const [
                Expanded(
                  child: Center(
                    child: Text('Start your conversation now :)'),
                  ),
                ),
                _MessageBar(),
              ],
            );
          } else if (state is ChatError) {
            return Center(child: Text(state.message));
          }
          throw UnimplementedError();
        },
      ),
    );
  }
}

/// Set of widget that contains TextField and Button to submit message
class _MessageBar extends StatefulWidget {
  const _MessageBar({
    Key? key,
  }) : super(key: key);

  @override
  State<_MessageBar> createState() => _MessageBarState();
}

class _MessageBarState extends State<_MessageBar> {
  late final TextEditingController _textController;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Theme.of(context).cardColor,
      child: Padding(
        padding: EdgeInsets.only(
          top: 8,
          left: 8,
          right: 8,
          bottom: MediaQuery.of(context).padding.bottom,
        ),
        child: Row(
          children: [
            Expanded(
              child: TextFormField(
                keyboardType: TextInputType.text,
                maxLines: null,
                autofocus: true,
                controller: _textController,
                decoration: const InputDecoration(
                  hintText: 'Type a message',
                  border: InputBorder.none,
                  focusedBorder: InputBorder.none,
                  contentPadding: EdgeInsets.all(8),
                ),
              ),
            ),
            TextButton(
              onPressed: () => _submitMessage(),
              child: const Text('Send'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void initState() {
    _textController = TextEditingController();
    super.initState();
  }

  @override
  void dispose() {
    _textController.dispose();
    super.dispose();
  }

  void _submitMessage() async {
    final text = _textController.text;
    if (text.isEmpty) {
      return;
    }
    BlocProvider.of<ChatCubit>(context).sendMessage(text);
    _textController.clear();
  }
}

class _ChatBubble extends StatelessWidget {
  const _ChatBubble({
    Key? key,
    required this.message,
  }) : super(key: key);

  final Message message;

  @override
  Widget build(BuildContext context) {
    List<Widget> chatContents = [
      if (!message.isMine) UserAvatar(userId: message.profileId),
      const SizedBox(width: 12),
      Flexible(
        child: Container(
          padding: const EdgeInsets.symmetric(
            vertical: 8,
            horizontal: 12,
          ),
          decoration: BoxDecoration(
            color: message.isMine
                ? Colors.grey[300]
                : Theme.of(context).primaryColor,
            borderRadius: BorderRadius.circular(8),
          ),
          child: Text(message.content),
        ),
      ),
      const SizedBox(width: 12),
      Text(format(message.createdAt, locale: 'en_short')),
      const SizedBox(width: 60),
    ];
    if (message.isMine) {
      chatContents = chatContents.reversed.toList();
    }
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18),
      child: Row(
        mainAxisAlignment:
            message.isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
        children: chatContents,
      ),
    );
  }
}

Step 3: 登録・ログインページの更新

登録とログインページはシンプルなデータ構造になっているので、状態管理は必要ないのですが、Deeplinkを使ってユーザーをアプリに引き戻してあげるために少しだけ手を加えてあげる必要があります。

具体的には、何をきっかけにユーザーを部屋一覧ページにナビゲーションするかが変わってきます。今まではシンプルに.signUp()が完了したきっかけでユーザーを部屋一覧ページに遷移させていました。今回のアプリは.signUp()が完了した段階ではまだ確認メールがユーザーのメールアドレスに飛んだだけで、まだ登録が完了したわけではありません。なので、今回はsupabase.auth.onAuthStateChangeを使ってユーザーの認証情報にリスナーを張ってあげて、そのユーザーがログインをしたタイミングで部屋一覧ページに遷移してあげます。

dart title=lib/pages/register_page.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:my_chat_app/pages/login_page.dart';
import 'package:my_chat_app/pages/rooms_page.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class RegisterPage extends StatefulWidget {
  const RegisterPage(
      {Key? key, required this.isRegistering})
      : super(key: key);

  static Route<void> route({bool isRegistering = false}) {
    return MaterialPageRoute(
      builder: (context) =>
          RegisterPage(isRegistering: isRegistering),
    );
  }

  final bool isRegistering;

  @override
  State<RegisterPage> createState() => _RegisterPageState();
}

class _RegisterPageState extends State<RegisterPage> {
  final bool _isLoading = false;

  final _formKey = GlobalKey<FormState>();

  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _usernameController = TextEditingController();

  late final StreamSubscription<AuthState>
      _authSubscription;

  @override
  void initState() {
    super.initState();

    bool haveNavigated = false;
    // Listen to auth state to redirect user when the user clicks on confirmation link
    _authSubscription =
        supabase.auth.onAuthStateChange.listen((data) {
      final session = data.session;
      if (session != null && !haveNavigated) {
        haveNavigated = true;
        Navigator.of(context)
            .pushReplacement(RoomsPage.route());
      }
    });
  }

  @override
  void dispose() {
    super.dispose();

    // Dispose subscription when no longer needed
    _authSubscription.cancel();
  }

  Future<void> _signUp() async {
    final isValid = _formKey.currentState!.validate();
    if (!isValid) {
      return;
    }
    final email = _emailController.text;
    final password = _passwordController.text;
    final username = _usernameController.text;
    try {
      await supabase.auth.signUp(
        email: email,
        password: password,
        data: {'username': username},
        emailRedirectTo: 'io.supabase.chat://login',
      );
      context.showSnackBar(
          message:
              'Please check your inbox for confirmation email.');
    } on AuthException catch (error) {
      context.showErrorSnackBar(message: error.message);
    } catch (error) {
      debugPrint(error.toString());
      context.showErrorSnackBar(
          message: unexpectedErrorMessage);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Register'),
      ),
      body: Form(
        key: _formKey,
        child: ListView(
          padding: formPadding,
          children: [
            TextFormField(
              controller: _emailController,
              decoration: const InputDecoration(
                label: Text('Email'),
              ),
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Required';
                }
                return null;
              },
              keyboardType: TextInputType.emailAddress,
            ),
            spacer,
            TextFormField(
              controller: _passwordController,
              obscureText: true,
              decoration: const InputDecoration(
                label: Text('Password'),
              ),
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Required';
                }
                if (val.length < 6) {
                  return '6 characters minimum';
                }
                return null;
              },
            ),
            spacer,
            TextFormField(
              controller: _usernameController,
              decoration: const InputDecoration(
                label: Text('Username'),
              ),
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Required';
                }
                final isValid =
                    RegExp(r'^[A-Za-z0-9_]{3,24}$')
                        .hasMatch(val);
                if (!isValid) {
                  return '3-24 long with alphanumeric or underscore';
                }
                return null;
              },
            ),
            spacer,
            ElevatedButton(
              onPressed: _isLoading ? null : _signUp,
              child: const Text('Register'),
            ),
            spacer,
            TextButton(
                onPressed: () {
                  Navigator.of(context)
                      .push(LoginPage.route());
                },
                child:
                    const Text('I already have an account'))
          ],
        ),
      ),
    );
  }
}

ログインページはめっちゃシンプルになります。ただ単にメールアドレスとパスワードを受け取って、signInWithPassword()を呼んであげるだけです。画面遷移のロジックなどは登録ページにあるので、ログインページには遷移関係のコードは一歳埋め込まなくても大丈夫です!

dart title=lib/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

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

  static Route<void> route() {
    return MaterialPageRoute(
        builder: (context) => const LoginPage());
  }

  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  bool _isLoading = false;
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  Future<void> _signIn() async {
    setState(() {
      _isLoading = true;
    });
    try {
      await supabase.auth.signInWithPassword(
        email: _emailController.text,
        password: _passwordController.text,
      );
    } on AuthException catch (error) {
      context.showErrorSnackBar(message: error.message);
    } catch (_) {
      context.showErrorSnackBar(
          message: unexpectedErrorMessage);
    }
    if (mounted) {
      setState(() {
        _isLoading = true;
      });
    }
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign In')),
      body: ListView(
        padding: formPadding,
        children: [
          TextFormField(
            controller: _emailController,
            decoration:
                const InputDecoration(labelText: 'Email'),
            keyboardType: TextInputType.emailAddress,
          ),
          spacer,
          TextFormField(
            controller: _passwordController,
            decoration: const InputDecoration(
                labelText: 'Password'),
            obscureText: true,
          ),
          spacer,
          ElevatedButton(
            onPressed: _isLoading ? null : _signIn,
            child: const Text('Login'),
          ),
        ],
      ),
    );
  }
}

ユーザーがアプリを開いた際に表示されるSplashPageも少しだけ手を加えてあげましょう。アプリを開いた段階でユーザーがログイン済みの場合部屋一覧ページに遷移してあげるようにします。

dart title=lib/pages/splash_page.dart
import 'package:flutter/material.dart';
import 'package:my_chat_app/pages/register_page.dart';
import 'package:my_chat_app/pages/rooms_page.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

/// Page to redirect users to the appropriate page depending on the initial auth state
class SplashPage extends StatefulWidget {
  const SplashPage({Key? key}) : super(key: key);

  @override
  SplashPageState createState() => SplashPageState();
}

class SplashPageState extends State<SplashPage> {
  @override
  void initState() {
    getInitialSession();
    super.initState();
  }

  Future<void> getInitialSession() async {
    // quick and dirty way to wait for the widget to mount
    await Future.delayed(Duration.zero);

    try {
      final session =
          await SupabaseAuth.instance.initialSession;
      if (session == null) {
        Navigator.of(context).pushAndRemoveUntil(
            RegisterPage.route(), (_) => false);
      } else {
        Navigator.of(context).pushAndRemoveUntil(
            RoomsPage.route(), (_) => false);
      }
    } catch (_) {
      context.showErrorSnackBar(
        message: 'Error occurred during session refresh',
      );
      Navigator.of(context).pushAndRemoveUntil(
          RegisterPage.route(), (_) => false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(child: CircularProgressIndicator()),
    );
  }
}

最後にユーザーのプロフィール情報を取得するためのProfilesCubitを作ってあげましょう。ユーザーのプロフィール情報やアイコンなどはページを跨いでアプリ全体で使うデータになるので一個共通で用意してあげます。

dart title=lib/cubits/profiles_state.dart
part of 'profiles_cubit.dart';

@immutable
abstract class ProfilesState {}

class ProfilesInitial extends ProfilesState {}

class ProfilesLoaded extends ProfilesState {
  ProfilesLoaded({
    required this.profiles,
  });

  final Map<String, Profile?> profiles;
}
dart title=lib/cubits/profiles_cubit.dart
import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:meta/meta.dart';
import 'package:my_chat_app/models/profile.dart';
import 'package:my_chat_app/utils/constants.dart';

part 'profiles_state.dart';

class ProfilesCubit extends Cubit<ProfilesState> {
  ProfilesCubit() : super(ProfilesInitial());

  /// Map of app users cache in memory with profile_id as the key
  final Map<String, Profile?> _profiles = {};

  Future<void> getProfile(String userId) async {
    if (_profiles[userId] != null) {
      return;
    }

    final data = await supabase
        .from('profiles')
        .select()
        .match({'id': userId}).single();

    if (data == null) {
      return;
    }
    _profiles[userId] = Profile.fromMap(data);

    emit(ProfilesLoaded(profiles: _profiles));
  }
}

main.dartでアプリ全体からProfilesCubitにアクセスできるようにしてあげれば完了です。

dart title=lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';
import 'package:my_chat_app/utils/constants.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:my_chat_app/pages/splash_page.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Supabase.initialize(
    // TODO: Replace credentials with your own
    url: 'supabase_url',
    anonKey: 'supabase_anon_key',
    authCallbackUrlHostname: 'login',
  );

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider<ProfilesCubit>(
      create: (context) => ProfilesCubit(),
      child: MaterialApp(
        title: 'SupaChat',
        debugShowCheckedModeBanner: false,
        theme: appTheme,
        home: const SplashPage(),
      ),
    );
  }
}

これでアプリ側のコードの書き換えは完了です。

Step 4: RLSを使ってデータベースをセキュアに

アプリ側のコードの書き換えは完了したのですが、この状態でアプリを開くと全ユーザーの所属する部屋が見えてしまいます。これは、まだSupabase側でRow Level Security(日本語で行レベルセキュリティ)の設定が行われていないからなので、設定をしてアプリをセキュアにしちゃいましょう。RLSを使うとどのユーザーがデータベースのどの行にアクセスできるかどうかを定義することができます。

こちらのSQLをSupabaseのダッシュボードから走らせちゃってください。

-- Returns true if the signed in user is a participant of the room
create or replace function is_room_participant(room_id uuid)
returns boolean as $$
  select exists(
    select 1
    from room_participants
    where room_id = is_room_participant.room_id and profile_id = auth.uid()
  );
$$ language sql security definer;


-- *** Row level security polities ***


alter table public.profiles enable row level security;
create policy "Public profiles are viewable by everyone."
  on public.profiles for select using (true);


alter table public.rooms enable row level security;
create policy "Users can view rooms that they have joined"
  on public.rooms for select using (is_room_participant(id));


alter table public.room_participants enable row level security;
create policy "Participants of the room can view other participants."
  on public.room_participants for select using (is_room_participant(room_id));


alter table public.messages enable row level security;
create policy "Users can view messages on rooms they are in."
  on public.messages for select using (is_room_participant(room_id));
create policy "Users can insert messages on rooms they are in."
  on public.messages for insert with check (is_room_participant(room_id) and profile_id = auth.uid());

一番上にis_room_participantという関数の定義があったことに気づきましたでしょうか?この関数に特定の部屋情報を渡してあげると、それだけで該当ユーザーがその部屋に入っているかどうか返してくれます。あとはこれをあちこちのポリシーに渡してあげるだけといった感じです。

以上の設定が完了したらアプリに戻ってみましょう。自分が所属する部屋一覧しか見えなくなっているはずです。

最後に

今回はblocを状態管理ライブラリーとして使いましたが、できたら状態管理ライブラリーなしでサンプルアプリが作れたらなと思っていました。ただ、どうしても部屋一覧ページが綺麗にできなくてしょうがなく状態管理ライブラリーを使いましたが、今度機会があるときにRiverpod版も作ってみようかななんて思いました。

もう一つこんな機能ができたら素敵だなと思った点があります。今このアプリは新しくユーザーとチャットを始めるために部屋一覧上部の新規ユーザー一覧の中からチャットしたいユーザーをタップしてチャットをする形になっていますが、ここに改善の余地を感じます。例えば、SupabaseのPresence機能を使うと今リアルタイムにアプリを開いている人一覧を取得してくることができます。こちらを使うとよりそれっぽいアプリになりそうですね!

もし何か質問がある方はぜひTwitterで聞いていただくか、SupabaseのDiscordに入って聞いてみてください!Discordの方は最近日本語専用チャンネルもできたので日本語での質問もお待ちしております!

14
6
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?