クイックスタート:Flutter
イントロ
この例では、SupabaseとFlutterを使って、シンプルなユーザー管理アプリを(ゼロから)構築する手順を紹介します。内容は以下のとおりです。
- Supabase Database:ユーザーデータを保存するためのPostgresデータベースです。
- Supabase Auth:ユーザーはマジックリンクでサインインできます(パスワードは不要、メールのみ)。
- 行単位セキュリティー:データは保護されており、個人が自分のデータにしかアクセスできないようになっています。
- インスタントAPI:データベースのテーブルを作成すると、APIが自動的に生成されます。
このガイドの最後には、ユーザーがログインして基本的なプロフィール情報を更新できるアプリが完成します。
GitHub
どこかで行き詰ったら、このリポジトリーを見てみましょう。
プロジェクトのセットアップ
ビルドを開始する前に、データベースとAPIをセットアップします。Supabaseで新しいプロジェクトを立ち上げ、データベース内に「スキーマ」を作成するだけです。
プロジェクトの作成
- app.supabase.comにアクセスします。
- 「New Project」をクリックします。
- プロジェクトの詳細を入力します。
- 新しいデータベースが起動するのを待ちます。
データベーススキーマの設定
これからデータベース・スキーマを設定します。SQLエディターの「User Management Starter」クイック・スターターを使用するか、下記のSQLをコピー/ペーストして自分で実行できます。
- UI
- SQL
1. 「SQL」セクションに移動します。
2. 「User Management Starter」をクリックします。
3. 「Run」をクリックします。
-- パブリックで「profiles」テーブルを作成
create table profiles (
id uuid references auth.users not null,
updated_at timestamp with time zone,
username text unique,
avatar_url text,
website text,
primary key (id),
unique(username),
constraint username_length check (char_length(username) >= 3)
);
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 );
-- リアルタイムをセットアップ
begin;
drop publication if exists supabase_realtime;
create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;
-- ストレージをセットアップ
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true);
create policy "Anyone can upload an avatar."
on storage.objects for insert
with check ( bucket_id = 'avatars' );
APIキーの取得
データベースのテーブルをいくつか作成したので、自動生成されたAPIを使ってデータを挿入する準備ができました。
API設定からURLとanon
キーを取得する必要があります。
- UI
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側の設定はここまでで、あとはプラットフォームごとの設定です。
Androidの場合は、ディープ・リンクを有効にするためのインテント・フィルターを追加します。
<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を追加してディープ・リンクを有効にします。
<!-- ... 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クライアントを初期化します。
これらの変数はブラウザー上で公開されますが、データベースでは行単位・セキュリティが有効になっているので、全く問題ありません。
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
クラスが用意されており、これを継承することで様々なディープ・リンク・イベントに対応ができます。
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
を継承します。
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行で呼び出せるように拡張メソッドの定義も行います。
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
を継承しており、ユーザーの認証状態に応じて適切なページにリダイレクトします。
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
も継承します。
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
を継承することに注意してください。
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
を更新しましょう。
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のプロジェクトには、写真や動画などの大容量ファイルを管理するためのストレージが設定されています。
パブリックなバケットにする
パブリックに共有可能な画像として保存する予定です。
avatars
のバケットがパブリックに設定されていることを確認し、もしそうでなければ、バケット名にカーソルを合わせたときに表示されるドットメニューをクリックしてパブリックに変更してください。
バケットがパブリックに設定されている場合は、バケット名の横にオレンジ色のPublic
バッジが表示されます。
アカウントページへの画像アップロード機能の追加
ここではimage_picker
プラグインを使って、デバイスから画像を選択します。
以下のコマンドを実行してインストールしてください。
flutter pub add image_picker
image_picker
を使用するには、プラットフォームによっていくつかの追加準備が必要です。image_picker
のREADME.mdに記載されている指示に従って、使用しているプラットフォームに合わせて設定してください。
以上の準備が完了したら、いよいよコーディングに入ります。
アップロードウィジェットの作成
ユーザーがプロフィール写真をアップロードできるように、ユーザーのアバターを作成しましょう。 新しくコンポーネントを作成することからはじめます。
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
を更新するロジックを追加します。
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を使った、完全に機能するユーザー管理アプリが完成しました。
次のステップ
この段階で、完全に機能するアプリケーションが完成しました。
- 何か疑問がありましたら、こちらで質問してください
- サインイン:app.supabase.com