メインコンテンツまでスキップ

クイックスタート:Flutter

イントロ

この例では、SupabaseとFlutterを使って、シンプルなユーザー管理アプリを(ゼロから)構築する手順を紹介します。内容は以下のとおりです。

  • Supabase Database:ユーザーデータを保存するためのPostgresデータベースです。
  • Supabase Auth:ユーザーはマジックリンクでサインインできます(パスワードは不要、メールのみ)。
  • 行単位セキュリティー:データは保護されており、個人が自分のデータにしかアクセスできないようになっています。
  • インスタントAPI:データベースのテーブルを作成すると、APIが自動的に生成されます。

このガイドの最後には、ユーザーがログインして基本的なプロフィール情報を更新できるアプリが完成します。

Supabase User Management example

GitHub

どこかで行き詰ったら、このリポジトリーを見てみましょう。

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

ビルドを開始する前に、データベースとAPIをセットアップします。Supabaseで新しいプロジェクトを立ち上げ、データベース内に「スキーマ」を作成するだけです。

プロジェクトの作成

  1. app.supabase.comにアクセスします。
  2. 「New Project」をクリックします。
  3. プロジェクトの詳細を入力します。
  4. 新しいデータベースが起動するのを待ちます。

データベーススキーマの設定

これからデータベース・スキーマを設定します。SQLエディターの「User Management Starter」クイック・スターターを使用するか、下記のSQLをコピー/ペーストして自分で実行できます。

1. 「SQL」セクションに移動します。
2. 「User Management Starter」をクリックします。
3. 「Run」をクリックします。

APIキーの取得

データベースのテーブルをいくつか作成したので、自動生成されたAPIを使ってデータを挿入する準備ができました。 API設定からURLとanonキーを取得する必要があります。

1. 「Settings」セクションに移動します。
2. サイドバーの「API」をリックします。
3. そのページでAPI URLを探します。
4. 「anon」キーを探します。

アプリの構築

それでは、Flutterアプリを一から作ってみましょう。

Flutterアプリの初期化

flutter createを使ってsupabase_quickstartというアプリを初期化します。

flutter create supabase_quickstart

そして、唯一追加する必要がある依存関係のsupabase_flutterをインストールしましょう。

以下のコマンドを実行して、最新バージョンのsupabase_flutterをプロジェクトに導入します。

flutter pub add supabase_flutter

flutter pub getを実行して、依存関係をインストールします。

ディープ・リンクの設定

依存関係のインストールが完了したので、マジック・リンクやOAuthでログインしたユーザーがアプリに戻ってこれるように、ディープ・リンクを設定しましょう。

1. 「Authentication」セクションに移動します。
2. サイドバーの「Settings」セクションをクリックします。
3. 「Additional Redirect URLs」入力フィールドに`io.supabase.flutterquickstart://login-callback/`と入力します。
4. 保存します。

Supabase console deep link setting

Supabase側の設定はここまでで、あとはプラットフォームごとの設定です。

Androidの場合は、ディープ・リンクを有効にするためのインテント・フィルターを追加します。

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.flutterquickstart"
android:host="login-callback" />
</intent-filter>

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

iOSの場合、CFBundleURLTypesを追加してディープ・リンクを有効にします。

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

Webの場合は、追加の設定はありません。

main関数

ディープリンクの準備ができたので、先ほどコピーしたAPI認証情報を使って、main関数内でSupabaseクライアントを初期化します。 これらの変数はブラウザー上で公開されますが、データベースでは行単位・セキュリティが有効になっているので、全く問題ありません。

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

await Supabase.initialize(
url: '[YOUR_SUPABASE_URL]',
anonKey: '[YOUR_SUPABASE_ANON_KEY]',
);
runApp(MyApp());
}

AuthStateの設定

AndroidとiOSでディープ・リンクを扱うために、そのためのクラスを作ってみましょう。 supabase_flutterプラグインにはSupabaseAuthStateクラスが用意されており、これを継承することで様々なディープ・リンク・イベントに対応ができます。

lib/components/auth_state.dart
import 'package:flutter/material.dart';
import 'package:supabase/supabase.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/utils/constants.dart';

class AuthState<T extends StatefulWidget> extends SupabaseAuthState<T> {

void onUnauthenticated() {
if (mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}


void onAuthenticated(Session session) {
if (mounted) {
Navigator.of(context)
.pushNamedAndRemoveUntil('/account', (route) => false);
}
}


void onPasswordRecovery(Session session) {}


void onErrorAuthenticating(String message) {
context.showErrorSnackBar(message: message);
}
}

AuthRequiredStateの設定

ユーザーがサイン・インしている場合にのみ、特定のページを表示したい場合があります。 そのためには、便利なAuthRequiredStateクラスを作成し、ユーザーの認証が必要なページに継承させることができます。 AuthRequiredStateは、supabase_flutterパッケージで提供されているSupabaseAuthRequiredStateを継承します。

lib/components/auth_required_state.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

class AuthRequiredState<T extends StatefulWidget>
extends SupabaseAuthRequiredState<T> {

void onUnauthenticated() {
/// Users will be sent back to the LoginPage if they sign out.
if (mounted) {
/// Users will be sent back to the LoginPage if they sign out.
Navigator.of(context).pushNamedAndRemoveUntil('/login', (route) => false);
}
}
}

Supabaseクライアントを使いやすくするために、定数ファイルを作成します。 また、showSnackBarを1行で呼び出せるように拡張メソッドの定義も行います。

lib/utils/constants.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabase = Supabase.instance.client;

extension ShowSnackBar on BuildContext {
void showSnackBar({
required String message,
Color backgroundColor = Colors.white,
}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
));
}

void showErrorSnackBar({required String message}) {
showSnackBar(message: message, backgroundColor: Colors.red);
}
}

スプラッシュ画面の作成

ユーザーがアプリを開いた直後に表示されるスプラッシュ画面を作ってみましょう。 このスプラッシュスクリーンはAuthStateを継承しており、ユーザーの認証状態に応じて適切なページにリダイレクトします。

lib/pages/splash_page.dart
import 'package:flutter/material.dart';
import 'package:supabase_quickstart/components/auth_state.dart';

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


_SplashPageState createState() => _SplashPageState();
}

class _SplashPageState extends AuthState<SplashPage> {

void initState() {
recoverSupabaseSession();
super.initState();
}


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

ログインページの作成

ログインやサイン・アップを管理するためのFlutterウィジェットを作成しましょう。 マジック・リンクを使用することで、ユーザーはパスワードを使わずにメールでサイン・インができます。 このページは、ユーザーのログインを処理するので、AuthStateも継承します。

lib/pages/login_page.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:supabase/supabase.dart';
import 'package:supabase_quickstart/components/auth_state.dart';
import 'package:supabase_quickstart/utils/constants.dart';

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


_LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends AuthState<LoginPage> {
bool _isLoading = false;
late final TextEditingController _emailController;

Future<void> _signIn() async {
setState(() {
_isLoading = true;
});
final response = await supabase.auth.signIn(
email: _emailController.text,
options: AuthOptions(
redirectTo: kIsWeb
? null
: 'io.supabase.flutterquickstart://login-callback/'));
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
} else {
context.showSnackBar(message: 'Check your email for login link!');
_emailController.clear();
}

setState(() {
_isLoading = false;
});
}


void initState() {
super.initState();
_emailController = TextEditingController();
}


void dispose() {
_emailController.dispose();
super.dispose();
}


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'),
),
],
),
);
}
}

アカウント設定ページ

ユーザーがサイン・インした後、プロフィールの詳細を編集したり、アカウントを管理できるようにします。 そのためにaccount_page.dartという新しいウィジェットを作成します。 このページを表示するにはユーザーの認証が必要なので、このページはAuthRequiredStateを継承することに注意してください。

lib/pages/account_page.dart
import 'package:flutter/material.dart';
import 'package:supabase/supabase.dart';
import 'package:supabase_quickstart/components/auth_required_state.dart';
import 'package:supabase_quickstart/utils/constants.dart';

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


_AccountPageState createState() => _AccountPageState();
}

class _AccountPageState extends AuthRequiredState<AccountPage> {
final _usernameController = TextEditingController();
final _websiteController = TextEditingController();
var _loading = false;

/// Called once a user id is received within `onAuthenticated()`
Future<void> _getProfile(String userId) async {
setState(() {
_loading = true;
});
final response = await supabase
.from('profiles')
.select()
.eq('id', userId)
.single()
.execute();
final error = response.error;
if (error != null && response.status != 406) {
context.showErrorSnackBar(message: error.message);
}
final data = response.data;
if (data != null) {
_usernameController.text = (data['username'] ?? '') as String;
_websiteController.text = (data['website'] ?? '') as String;
}
setState(() {
_loading = false;
});
}

/// Called when user taps `Update` button
Future<void> _updateProfile() async {
setState(() {
_loading = true;
});
final userName = _usernameController.text;
final website = _websiteController.text;
final user = supabase.auth.currentUser;
final updates = {
'id': user!.id,
'username': userName,
'website': website,
'updated_at': DateTime.now().toIso8601String(),
};
final response = await supabase.from('profiles').upsert(updates).execute();
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
} else {
context.showSnackBar(message: 'Successfully updated profile!');
}
setState(() {
_loading = false;
});
}

Future<void> _signOut() async {
final response = await supabase.auth.signOut();
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
}
}


void onAuthenticated(Session session) {
final user = session.user;
if (user != null) {
_getProfile(user.id);
}
}


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


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: 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: _updateProfile,
child: Text(_loading ? 'Saving...' : 'Update')),
const SizedBox(height: 18),
ElevatedButton(onPressed: _signOut, child: const Text('Sign Out')),
],
),
);
}
}

ローンチ

すべてのコンポーネントがそろったところで、lib/main.dartを更新しましょう。

lib/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 {
WidgetsFlutterBinding.ensureInitialized();

await Supabase.initialize(
// TODO: Replace credentials with your own
url: '[YOUR_SUPABASE_URL]',
anonKey: '[YOUR_SUPABASE_ANNON_KEY]',
);
runApp(MyApp());
}

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
title: 'Supabase Flutter',
theme: ThemeData.dark().copyWith(
primaryColor: Colors.green,
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
onPrimary: Colors.white,
primary: Colors.green,
),
),
),
initialRoute: '/',
routes: <String, WidgetBuilder>{
'/': (_) => const SplashPage(),
'/login': (_) => const LoginPage(),
'/account': (_) => const AccountPage(),
},
);
}
}

更新が完了したら、ターミナルウィンドウでこれを実行し、AndroidまたはiOSで起動します。

flutter run

Webの場合は、以下のコマンドを実行して、localhost:3000で起動します。

flutter run -d web-server --web-hostname localhost --web-port 3000

そして、ブラウザをlocalhost:3000に開くと、完成したアプリが表示されるはずです。

Supabase User Management example

おまけ:プロフィール写真

Supabaseのプロジェクトには、写真や動画などの大容量ファイルを管理するためのストレージが設定されています。

パブリックなバケットにする

パブリックに共有可能な画像として保存する予定です。 avatarsのバケットがパブリックに設定されていることを確認し、もしそうでなければ、バケット名にカーソルを合わせたときに表示されるドットメニューをクリックしてパブリックに変更してください。 バケットがパブリックに設定されている場合は、バケット名の横にオレンジ色のPublicバッジが表示されます。

アカウントページへの画像アップロード機能の追加

ここではimage_pickerプラグインを使って、デバイスから画像を選択します。

以下のコマンドを実行してインストールしてください。

flutter pub add image_picker

image_pickerを使用するには、プラットフォームによっていくつかの追加準備が必要です。image_pickerのREADME.mdに記載されている指示に従って、使用しているプラットフォームに合わせて設定してください。

以上の準備が完了したら、いよいよコーディングに入ります。

アップロードウィジェットの作成

ユーザーがプロフィール写真をアップロードできるように、ユーザーのアバターを作成しましょう。 新しくコンポーネントを作成することからはじめます。

lib/components/avatar.dart
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:supabase_quickstart/utils/constants.dart';

class Avatar extends StatefulWidget {
const Avatar({
Key? key,
required this.imageUrl,
required this.onUpload,
}) : super(key: key);

final String? imageUrl;
final void Function(String) onUpload;


_AvatarState createState() => _AvatarState();
}

class _AvatarState extends State<Avatar> {
bool _isLoading = false;


Widget build(BuildContext context) {
return Column(
children: [
if (widget.imageUrl == null)
Container(
width: 150,
height: 150,
color: Colors.grey,
child: const Center(
child: Text('No Image'),
),
)
else
Image.network(
widget.imageUrl!,
width: 150,
height: 150,
fit: BoxFit.cover,
),
ElevatedButton(
onPressed: _isLoading ? null : _upload,
child: const Text('Upload'),
),
],
);
}

Future<void> _upload() async {
final _picker = ImagePicker();
final imageFile = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 300,
maxHeight: 300,
);
if (imageFile == null) {
return;
}
setState(() => _isLoading = true);

final bytes = await imageFile.readAsBytes();
final fileExt = imageFile.path.split('.').last;
final fileName = '${DateTime.now().toIso8601String()}.$fileExt';
final filePath = fileName;
final response =
await supabase.storage.from('avatars').uploadBinary(filePath, bytes);

setState(() => _isLoading = false);

final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
return;
}
final imageUrlResponse =
supabase.storage.from('avatars').getPublicUrl(filePath);
widget.onUpload(imageUrlResponse.data!);
}
}

新しいウィジェットの追加

そして、アカウントページにウィジェットを追加し、ユーザーが新しいアバターをアップロードするたびにavatar_urlを更新するロジックを追加します。

lib/pages/account_page.dart
import 'package:flutter/material.dart';
import 'package:supabase/supabase.dart';
import 'package:supabase_quickstart/components/auth_required_state.dart';
import 'package:supabase_quickstart/components/avatar.dart';
import 'package:supabase_quickstart/utils/constants.dart';

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


_AccountPageState createState() => _AccountPageState();
}

class _AccountPageState extends AuthRequiredState<AccountPage> {
final _usernameController = TextEditingController();
final _websiteController = TextEditingController();
String? _userId;
String? _avatarUrl;
var _loading = false;

/// Called once a user id is received within `onAuthenticated()`
Future<void> _getProfile(String userId) async {
setState(() {
_loading = true;
});
final response = await supabase
.from('profiles')
.select()
.eq('id', userId)
.single()
.execute();
final error = response.error;
if (error != null && response.status != 406) {
context.showErrorSnackBar(message: error.message);
}
final data = response.data;
if (data != null) {
_usernameController.text = (data['username'] ?? '') as String;
_websiteController.text = (data['website'] ?? '') as String;
_avatarUrl = (data['avatar_url'] ?? '') as String;
}
setState(() {
_loading = false;
});
}

/// Called when user taps `Update` button
Future<void> _updateProfile() async {
setState(() {
_loading = true;
});
final userName = _usernameController.text;
final website = _websiteController.text;
final user = supabase.auth.currentUser;
final updates = {
'id': user!.id,
'username': userName,
'website': website,
'updated_at': DateTime.now().toIso8601String(),
};
final response = await supabase.from('profiles').upsert(updates).execute();
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
} else {
context.showSnackBar(message: 'Successfully updated profile!');
}
setState(() {
_loading = false;
});
}

Future<void> _signOut() async {
final response = await supabase.auth.signOut();
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
}
Navigator.of(context).pushReplacementNamed('/login');
}

/// Called when image has been uploaded to Supabase storage from within Avatar widget
Future<void> _onUpload(String imageUrl) async {
final response = await supabase.from('profiles').upsert({
'id': _userId,
'avatar_url': imageUrl,
}).execute();
final error = response.error;
if (error != null) {
context.showErrorSnackBar(message: error.message);
}
setState(() {
_avatarUrl = imageUrl;
});
context.showSnackBar(message: 'Updated your profile image!');
}


void onAuthenticated(Session session) {
final user = session.user;
if (user != null) {
_userId = user.id;
_getProfile(user.id);
}
}


void onUnauthenticated() {
Navigator.of(context).pushReplacementNamed('/login');
}


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


Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
children: [
Avatar(
imageUrl: _avatarUrl,
onUpload: _onUpload,
),
const SizedBox(height: 18),
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: _updateProfile,
child: Text(_loading ? 'Saving...' : 'Update')),
const SizedBox(height: 18),
ElevatedButton(onPressed: _signOut, child: const Text('Sign Out')),
],
),
);
}
}

おめでとうございます。これで完成です。これで、FlutterとSupabaseを使った、完全に機能するユーザー管理アプリが完成しました。

次のステップ

この段階で、完全に機能するアプリケーションが完成しました。

FlutterとSupabaseに関するリソース