Zubora Code

Flutter x Supabase のユーザ登録のチュートリアルをやって Supabase の使い方を理解する

Flutter x Supabase のユーザ登録のチュートリアルをやって Supabase の使い方を理解します。

Published: 21 November, 2023
Revised: 21 November, 2023

概要

公式で用意頂いているチュートリアル Build a User Management App with Flutter のユーザー登録部分をやってFlutterとSupabaseの連携部分を理解します。自分なりにアウトプットをまとめながらやることで後から振り返った時に簡単に思い出せるようにしておきます。

チュートリアルのソースコードの完成形は以下で公開されています。

https://github.com/supabase/supabase/tree/master/examples/user-management/flutter-user-management


前回本ブログの「Supabase の DB を local 環境で動かして開発する」という記事でlocal環境でSupabase環境を構築しリモート環境にデプロイする流れでまとめており、実際に開発する際もこうすることになるはずなので、今回もその方針で書いていきます。


今回書いたソースコードは以下にpushしておきました。
https://github.com/tkugimot/flutter-supabase-quickstart

flutterアプリ新規作成

チュートリアルの方では先にSupabaseのセットアップをやっていますが、Supabaseのmigrationファイルも同じリポジトリで管理したいので、先にアプリの方を作成しておきます。

$ flutter create supabase_quickstart
$ cd supabase_quickstart


当然git管理はしたいので、git init しておきます。

$ git init
$ git add .
$ git commit -m 'Initial Commit'

IDEでプロジェクトを開いたら、supabase_flutter のパッケージをインストールしておきます。

dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  
  supabase_flutter: ^1.10.3. # Add this line
$ flutter pub get


Supabaseプロジェクトのセットアップ

まずlocal環境でSupabaseのプロジェクトを作成します。以下のコマンドで、supabaseディレクトリが作成されます。

$ supabase init

$ supabase start

http://localhost:54323 にアクセスすると、ダッシュボードを確認できるようになりました。

続いて migration ファイルを用意します。この手順はチュートリアルに記載されています。

$ supabase migration new user_management_starter

[datetime]_user_management_starter.sql というファイルが supabase/migrations に作成されました。このファイルの中に、チュートリアルに記載のSQLをコピペします。


-- Create a table for public profiles
create table profiles (
  id uuid references auth.users not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,

  constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
alter table profiles
  enable row level security;

create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

create policy "Users can insert their own profile." on profiles
  for insert with check (auth.uid() = id);

create policy "Users can update own profile." on profiles
  for update using (auth.uid() = id);

-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Set up Storage!
insert into storage.buckets (id, name)
  values ('avatars', 'avatars');

-- Set up access controls for storage.
-- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
  for select using (bucket_id = 'avatars');

create policy "Anyone can upload an avatar." on storage.objects
  for insert with check (bucket_id = 'avatars');

create policy "Anyone can update their own avatar." on storage.objects
  for update using (auth.uid() = owner) with check (bucket_id = 'avatars');

以下のコマンドで、migrationを反映します。

$ supabase db reset

テーブルが作成されたことを確認します。

$ psql 'postgresql://postgres:postgres@localhost:54322/postgres'
psql (16.0, server 15.1 (Ubuntu 15.1-1.pgdg20.04+1))
Type "help" for help.

postgres=> \d
                       List of relations
   Schema   |          Name           | Type  |     Owner      
------------+-------------------------+-------+----------------
 extensions | pg_stat_statements      | view  | supabase_admin
 extensions | pg_stat_statements_info | view  | supabase_admin
 public     | profiles                | table | postgres
(3 rows)


以下のリモート環境のSupabaseのダッシュボードから新規プロジェクトを作成し、local環境のSupabaseとlinkします。

https://supabase.com/dashboard/projects

作成されたプロジェクトのURLの以下の{projectId}の部分を使ってlinkします。

https://supabase.com/dashboard/project/{projectId}

$ supabase link --project-ref <projectId>
Enter your database password (or leave blank to skip): 
Finished supabase link.


local環境の内容をリモートに反映します。

$ supabase db push


deep link のセットアップ

ユーザーがサインインボタンをクリックした後にアプリに戻すための設定です。任意の値を設定可能なので、今回は「io.tkugimot.flutterquickstart://login-callback/」とします。

これをSupabaseのダッシュボードからRedirect URLsに設定します。


とりあえずiOS用に設定を追加します。

<!-- ... 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.tkugimot.flutterquickstart</string>
      </array>
    </dict>
  </array>
  <!-- ... other tags -->
</dict>
</plist>


Mainの実装修正

url, anonKeyはSupabaseのAPI Settingsから取得できます。

Future<void> main() async {
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
    authFlowType: AuthFlowType.pkce,
  );
  runApp(MyApp());
}

final supabase = Supabase.instance.client;


スプラッシュスクリーン(アプリ起動時の画面)作成

import 'package:flutter/material.dart';
import 'package:supabase_quickstart/main.dart';

class SplashPage extends StatefulWidget {
  const SplashPage({super.key});

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

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

  Future<void> _redirect() async {
    await Future.delayed(Duration.zero);
    if (!mounted) {
      return;
    }

    final session = supabase.auth.currentSession;
    if (session != null) {
      Navigator.of(context).pushReplacementNamed('/account');
    } else {
      Navigator.of(context).pushReplacementNamed('/login');
    }
  }

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


ログインページ作成

emailRedirectToの値は先ほど Redirect URLsに設定した値と同じで

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/main.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

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

class _LoginPageState extends State<LoginPage> {
  bool _isLoading = false;
  bool _redirecting = false;
  late final TextEditingController _emailController = TextEditingController();
  late final StreamSubscription<AuthState> _authStateSubscription;

  Future<void> _signIn() async {
    try {
      setState(() {
        _isLoading = true;
      });
      await supabase.auth.signInWithOtp(
        email: _emailController.text.trim(),
        emailRedirectTo:
            kIsWeb ? null : 'io.tkugimot.flutterquickstart://login-callback/',
      );
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Check your email for a login link!')),
        );
        _emailController.clear();
      }
    } on AuthException catch (error) {
      SnackBar(
        content: Text(error.message),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } catch (error) {
      SnackBar(
        content: const Text('Unexpected error occurred'),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } finally {
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
      }
    }
  }

  @override
  void initState() {
    _authStateSubscription = supabase.auth.onAuthStateChange.listen((data) {
      if (_redirecting) return;
      final session = data.session;
      if (session != null) {
        _redirecting = true;
        Navigator.of(context).pushReplacementNamed('/account');
      }
    });
    super.initState();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign In')),
      body: ListView(
        padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
        children: [
          const Text('Sign in via the magic link with your email below'),
          const SizedBox(height: 18),
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(labelText: 'Email'),
          ),
          const SizedBox(height: 18),
          ElevatedButton(
            onPressed: _isLoading ? null : _signIn,
            child: Text(_isLoading ? 'Loading' : 'Send Magic Link'),
          ),
        ],
      ),
    );
  }
}


アカウントページ作成

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/main.dart';

class AccountPage extends StatefulWidget {
  const AccountPage({super.key});

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

class _AccountPageState extends State<AccountPage> {
  final _usernameController = TextEditingController();
  final _websiteController = TextEditingController();

  var _loading = true;

  /// Called once a user id is received within `onAuthenticated()`
  Future<void> _getProfile() async {
    setState(() {
      _loading = true;
    });

    try {
      final userId = supabase.auth.currentUser!.id;
      final data = await supabase
          .from('profiles')
          .select<Map<String, dynamic>>()
          .eq('id', userId)
          .single();
      _usernameController.text = (data['username'] ?? '') as String;
      _websiteController.text = (data['website'] ?? '') as String;
    } on PostgrestException catch (error) {
      SnackBar(
        content: Text(error.message),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } catch (error) {
      SnackBar(
        content: const Text('Unexpected error occurred'),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } finally {
      if (mounted) {
        setState(() {
          _loading = false;
        });
      }
    }
  }

  /// Called when user taps `Update` button
  Future<void> _updateProfile() async {
    setState(() {
      _loading = true;
    });
    final userName = _usernameController.text.trim();
    final website = _websiteController.text.trim();
    final user = supabase.auth.currentUser;
    final updates = {
      'id': user!.id,
      'username': userName,
      'website': website,
      'updated_at': DateTime.now().toIso8601String(),
    };
    try {
      await supabase.from('profiles').upsert(updates);
      if (mounted) {
        const SnackBar(
          content: Text('Successfully updated profile!'),
        );
      }
    } on PostgrestException catch (error) {
      SnackBar(
        content: Text(error.message),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } catch (error) {
      SnackBar(
        content: const Text('Unexpected error occurred'),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } finally {
      if (mounted) {
        setState(() {
          _loading = false;
        });
      }
    }
  }

  Future<void> _signOut() async {
    try {
      await supabase.auth.signOut();
    } on AuthException catch (error) {
      SnackBar(
        content: Text(error.message),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } catch (error) {
      SnackBar(
        content: const Text('Unexpected error occurred'),
        backgroundColor: Theme.of(context).colorScheme.error,
      );
    } finally {
      if (mounted) {
        Navigator.of(context).pushReplacementNamed('/login');
      }
    }
  }

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

  @override
  void dispose() {
    _usernameController.dispose();
    _websiteController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: _loading
          ? const Center(child: CircularProgressIndicator())
          : ListView(
              padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
              children: [
                TextFormField(
                  controller: _usernameController,
                  decoration: const InputDecoration(labelText: 'User Name'),
                ),
                const SizedBox(height: 18),
                TextFormField(
                  controller: _websiteController,
                  decoration: const InputDecoration(labelText: 'Website'),
                ),
                const SizedBox(height: 18),
                ElevatedButton(
                  onPressed: _loading ? null : _updateProfile,
                  child: Text(_loading ? 'Saving...' : 'Update'),
                ),
                const SizedBox(height: 18),
                TextButton(onPressed: _signOut, child: const Text('Sign Out')),
              ],
            ),
    );
  }
}


main.dartの修正

import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/pages/account_page.dart';
import 'package:supabase_quickstart/pages/login_page.dart';
import 'package:supabase_quickstart/pages/splash_page.dart';

Future<void> main() async {
  await Supabase.initialize(
    url: 'YOUR_SUPABASE_URL',
    anonKey: 'YOUR_SUPABASE_ANON_KEY',
  );
  runApp(MyApp());
}

final supabase = Supabase.instance.client;

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Supabase Flutter',
      theme: ThemeData.dark().copyWith(
        primaryColor: Colors.green,
        textButtonTheme: TextButtonThemeData(
          style: TextButton.styleFrom(
            foregroundColor: Colors.green,
          ),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            foregroundColor: Colors.white,
            backgroundColor: Colors.green,
          ),
        ),
      ),
      initialRoute: '/',
      routes: <String, WidgetBuilder>{
        '/': (_) => const SplashPage(),
        '/login': (_) => const LoginPage(),
        '/account': (_) => const AccountPage(),
      },
    );
  }
}


ログインを試す

ここまで来たら実際にアプリを起動してログインとユーザー情報登録のフローを試してみます。

実機で動かす場合、まずデバッグモードをONにする必要がありますが、その後以下のようなエラーが出て起動しない場合は、 supabase_quickstart/ios ディレクトリを右クリックして「Flutter → Open iOS/macOS module in XCode」をクリックしてXCodeを開き、

Could not build the precompiled application for the device.
Error (Xcode): Signing for "Runner" requires a development team. Select a development team in the Signing & Capabilities editor.
/Users/tkugimot/workspace/flutter-apps/supabase_quickstart/ios/Runner.xcodeproj


Error launching application on iPhone.


TARGETS → Runner → Team を選択してください。

もしAppleのディベロッパーアカウントを持っていない場合は、アカウントの作成が必要です。(アカウントを持っていなくてもやり用はあるそうですが、すみません、私は調べていません)

以前「Flutterでアイコンをタップしたら音が出るシンプルなアプリを開発してリリースする 4/4 リリース前の残タスク」こちらのシリーズ記事で簡単なアプリを公開する手順をまとめていますので、興味があれば参考にしてください。

無事実機でアプリを起動できたらまず以下のような画面が開くので、自分のメールアドレスを入力して「Send Magic Link」をクリックします。

するとメールが送られてくるので、iPhoneでメールを開き、リンクをクリックします。

ここでOpenを押すと、アプリに戻り、以下のユーザー情報入力画面が開かれます。

適当な情報を入力してUpdateを押すと、

無事SupabaseのDBにもデータが登録されていました。


Supabase、かなり良さそうです。最近本業やプライベートが忙しくて中々時間が取れていませんでしたが、大体必要な技術は学べたので、そろそろFlutterのオリジナルアプリの開発を再開しようと思います。

Toshimitsu Kugimoto

Software Engineer

仕事では決済やメディアのWebやスマホアプリのBE開発、WebのFE開発をやっています。 JavaとTypeScriptをよく使います。プライベートではFlutterでのアプリ開発にも挑戦中です。