Flutter x Supabase のユーザ登録のチュートリアルをやって Supabase の使い方を理解する
Flutter x Supabase のユーザ登録のチュートリアルをやって Supabase の使い方を理解します。
概要
公式で用意頂いているチュートリアル 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のオリジナルアプリの開発を再開しようと思います。