入门

使用 Flutter 构建用户管理应用


本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用

Supabase User Management example

项目设置#

在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。

创建项目#

  1. 在 Supabase 控制面板中创建一个新项目
  2. 输入您的项目详细信息。
  3. 等待新的数据库启动。

设置数据库 schema#

现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。

  1. 转到控制面板中的 SQL 编辑器 页面。
  2. 点击 社区 > 快速入门 选项卡下的 用户管理 Starter
  3. 点击 运行

获取 API 详细信息#

现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。

为此,您需要从项目 连接 对话框中获取项目 URL 和密钥。

阅读 API 密钥文档 以全面了解所有密钥类型及其用途。

构建应用#

让我们从头开始构建 Flutter 应用。

初始化 Flutter 应用#

我们可以使用 flutter create 初始化一个名为 supabase_quickstart 的应用

1
flutter create supabase_quickstart

然后让我们安装唯一的额外依赖项:supabase_flutter

复制并粘贴以下行到您的 pubspec.yaml 文件中以安装软件包

1
supabase_flutter: ^2.0.0

运行 flutter pub get 来安装依赖项。

现在我们已经安装了依赖项,接下来设置深层链接。当用户点击魔术链接登录时,需要设置深层链接才能将用户带回应用程序。我们可以通过对 Flutter 应用程序进行微小调整来设置深层链接。

我们必须使用 io.supabase.flutterquickstart 作为 scheme。在此示例中,我们将使用 login-callback 作为深层链接的主机,但您可以将其更改为您想要的任何内容。

首先,在仪表板中将 io.supabase.flutterquickstart://login-callback/ 添加为新的重定向 URL

Supabase console deep link setting

Supabase 端就这些了,其余的是平台特定的设置

编辑 ios/Runner/Info.plist 文件。

添加 CFBundleURLTypes 以启用深层链接

1
<!-- ... other tags -->
2
<plist>
3
<dict>
4
<!-- ... other tags -->
5
6
<!-- Add this array for Deep Links -->
7
<key>CFBundleURLTypes</key>
8
<array>
9
<dict>
10
<key>CFBundleTypeRole</key>
11
<string>Editor</string>
12
<key>CFBundleURLSchemes</key>
13
<array>
14
<string>io.supabase.flutterquickstart</string>
15
</array>
16
</dict>
17
</array>
18
<!-- ... other tags -->
19
</dict>
20
</plist>

main 函数#

现在我们已经准备好深层链接,让我们在 main 函数中用您之前复制的 API 凭证初始化 Supabase 客户端。这些变量将暴露在应用程序中,这完全没问题,因为我们在数据库中启用了行级安全性

1
import 'package:flutter/material.dart';
2
import 'package:supabase_flutter/supabase_flutter.dart';
3
4
Future<void> main() async {
5
await Supabase.initialize(
6
url: 'YOUR_SUPABASE_URL',
7
anonKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY',
8
);
9
runApp(const MyApp());
10
}
11
12
final supabase = Supabase.instance.client;
13
14
class MyApp extends StatelessWidget {
15
const MyApp({super.key});
16
17
@override
18
Widget build(BuildContext context) {
19
return const MaterialApp(title: 'Supabase Flutter');
20
}
21
}
22
23
extension ContextExtension on BuildContext {
24
void showSnackBar(String message, {bool isError = false}) {
25
ScaffoldMessenger.of(this).showSnackBar(
26
SnackBar(
27
content: Text(message),
28
backgroundColor: isError
29
? Theme.of(this).colorScheme.error
30
: Theme.of(this).snackBarTheme.backgroundColor,
31
),
32
);
33
}
34
}

请注意,我们有一个 showSnackBar 扩展方法,我们将用它在应用程序中显示 snack bar。您可以将此方法定义在一个单独的文件中,并在需要时导入它,但为了简单起见,我们将在此处定义它。

设置登录页面#

让我们创建一个 Flutter 小部件来管理登录和注册。我们将使用魔术链接,因此用户无需密码即可通过电子邮件登录。

请注意,此页面使用 onAuthStateChange 设置了用户身份验证状态的侦听器。当用户点击魔术链接返回应用时,会触发一个新事件,此页面可以捕获该事件并相应地重定向用户。

1
import 'dart:async';
2
3
import 'package:flutter/foundation.dart';
4
import 'package:flutter/material.dart';
5
import 'package:supabase_flutter/supabase_flutter.dart';
6
import 'package:supabase_quickstart/main.dart';
7
import 'package:supabase_quickstart/pages/account_page.dart';
8
9
class LoginPage extends StatefulWidget {
10
const LoginPage({super.key});
11
12
@override
13
State<LoginPage> createState() => _LoginPageState();
14
}
15
16
class _LoginPageState extends State<LoginPage> {
17
bool _isLoading = false;
18
bool _redirecting = false;
19
late final TextEditingController _emailController = TextEditingController();
20
late final StreamSubscription<AuthState> _authStateSubscription;
21
22
Future<void> _signIn() async {
23
try {
24
setState(() {
25
_isLoading = true;
26
});
27
await supabase.auth.signInWithOtp(
28
email: _emailController.text.trim(),
29
emailRedirectTo:
30
kIsWeb ? null : 'io.supabase.flutterquickstart://login-callback/',
31
);
32
if (mounted) {
33
context.showSnackBar('Check your email for a login link!');
34
35
_emailController.clear();
36
}
37
} on AuthException catch (error) {
38
if (mounted) context.showSnackBar(error.message, isError: true);
39
} catch (error) {
40
if (mounted) {
41
context.showSnackBar('Unexpected error occurred', isError: true);
42
}
43
} finally {
44
if (mounted) {
45
setState(() {
46
_isLoading = false;
47
});
48
}
49
}
50
}
51
52
@override
53
void initState() {
54
_authStateSubscription = supabase.auth.onAuthStateChange.listen(
55
(data) {
56
if (_redirecting) return;
57
final session = data.session;
58
if (session != null) {
59
_redirecting = true;
60
Navigator.of(context).pushReplacement(
61
MaterialPageRoute(builder: (context) => const AccountPage()),
62
);
63
}
64
},
65
onError: (error) {
66
if (error is AuthException) {
67
context.showSnackBar(error.message, isError: true);
68
} else {
69
context.showSnackBar('Unexpected error occurred', isError: true);
70
}
71
},
72
);
73
super.initState();
74
}
75
76
@override
77
void dispose() {
78
_emailController.dispose();
79
_authStateSubscription.cancel();
80
super.dispose();
81
}
82
83
@override
84
Widget build(BuildContext context) {
85
return Scaffold(
86
appBar: AppBar(title: const Text('Sign In')),
87
body: ListView(
88
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
89
children: [
90
const Text('Sign in via the magic link with your email below'),
91
const SizedBox(height: 18),
92
TextFormField(
93
controller: _emailController,
94
decoration: const InputDecoration(labelText: 'Email'),
95
),
96
const SizedBox(height: 18),
97
ElevatedButton(
98
onPressed: _isLoading ? null : _signIn,
99
child: Text(_isLoading ? 'Sending...' : 'Send Magic Link'),
100
),
101
],
102
),
103
);
104
}
105
}

设置帐户页面#

用户登录后,我们可以允许他们编辑个人资料详细信息和管理他们的帐户。为此,我们创建一个名为 account_page.dart 的新小部件。

1
import 'package:flutter/material.dart';
2
import 'package:supabase_flutter/supabase_flutter.dart';
3
import 'package:supabase_quickstart/main.dart';
4
import 'package:supabase_quickstart/pages/login_page.dart';
5
6
class AccountPage extends StatefulWidget {
7
const AccountPage({super.key});
8
9
@override
10
State<AccountPage> createState() => _AccountPageState();
11
}
12
13
class _AccountPageState extends State<AccountPage> {
14
final _usernameController = TextEditingController();
15
final _websiteController = TextEditingController();
16
17
String? _avatarUrl;
18
var _loading = true;
19
20
/// Called once a user id is received within `onAuthenticated()`
21
Future<void> _getProfile() async {
22
setState(() {
23
_loading = true;
24
});
25
26
try {
27
final userId = supabase.auth.currentSession!.user.id;
28
final data =
29
await supabase.from('profiles').select().eq('id', userId).single();
30
_usernameController.text = (data['username'] ?? '') as String;
31
_websiteController.text = (data['website'] ?? '') as String;
32
_avatarUrl = (data['avatar_url'] ?? '') as String;
33
} on PostgrestException catch (error) {
34
if (mounted) context.showSnackBar(error.message, isError: true);
35
} catch (error) {
36
if (mounted) {
37
context.showSnackBar('Unexpected error occurred', isError: true);
38
}
39
} finally {
40
if (mounted) {
41
setState(() {
42
_loading = false;
43
});
44
}
45
}
46
}
47
48
/// Called when user taps `Update` button
49
Future<void> _updateProfile() async {
50
setState(() {
51
_loading = true;
52
});
53
final userName = _usernameController.text.trim();
54
final website = _websiteController.text.trim();
55
final user = supabase.auth.currentUser;
56
final updates = {
57
'id': user!.id,
58
'username': userName,
59
'website': website,
60
'updated_at': DateTime.now().toIso8601String(),
61
};
62
try {
63
await supabase.from('profiles').upsert(updates);
64
if (mounted) context.showSnackBar('Successfully updated profile!');
65
} on PostgrestException catch (error) {
66
if (mounted) context.showSnackBar(error.message, isError: true);
67
} catch (error) {
68
if (mounted) {
69
context.showSnackBar('Unexpected error occurred', isError: true);
70
}
71
} finally {
72
if (mounted) {
73
setState(() {
74
_loading = false;
75
});
76
}
77
}
78
}
79
80
Future<void> _signOut() async {
81
try {
82
await supabase.auth.signOut();
83
} on AuthException catch (error) {
84
if (mounted) context.showSnackBar(error.message, isError: true);
85
} catch (error) {
86
if (mounted) {
87
context.showSnackBar('Unexpected error occurred', isError: true);
88
}
89
} finally {
90
if (mounted) {
91
Navigator.of(context).pushReplacement(
92
MaterialPageRoute(builder: (_) => const LoginPage()),
93
);
94
}
95
}
96
}
97
98
@override
99
void initState() {
100
super.initState();
101
_getProfile();
102
}
103
104
@override
105
void dispose() {
106
_usernameController.dispose();
107
_websiteController.dispose();
108
super.dispose();
109
}
110
111
@override
112
Widget build(BuildContext context) {
113
return Scaffold(
114
appBar: AppBar(title: const Text('Profile')),
115
body: ListView(
116
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
117
children: [
118
TextFormField(
119
controller: _usernameController,
120
decoration: const InputDecoration(labelText: 'User Name'),
121
),
122
const SizedBox(height: 18),
123
TextFormField(
124
controller: _websiteController,
125
decoration: const InputDecoration(labelText: 'Website'),
126
),
127
const SizedBox(height: 18),
128
ElevatedButton(
129
onPressed: _loading ? null : _updateProfile,
130
child: Text(_loading ? 'Saving...' : 'Update'),
131
),
132
const SizedBox(height: 18),
133
TextButton(onPressed: _signOut, child: const Text('Sign Out')),
134
],
135
),
136
);
137
}
138
}

启动!#

现在我们已经将所有组件都放好,接下来更新 lib/main.dart。如果用户未通过身份验证,MaterialApphome(即向用户显示的初始页面)将是 LoginPage,如果用户已通过身份验证,则为 AccountPage。我们还包含了一些主题,使应用看起来更好看。

1
import 'package:flutter/material.dart';
2
import 'package:supabase_flutter/supabase_flutter.dart';
3
import 'package:supabase_quickstart/pages/account_page.dart';
4
import 'package:supabase_quickstart/pages/login_page.dart';
5
6
Future<void> main() async {
7
await Supabase.initialize(
8
url: 'YOUR_SUPABASE_URL',
9
anonKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY',
10
);
11
runApp(const MyApp());
12
}
13
14
final supabase = Supabase.instance.client;
15
16
class MyApp extends StatelessWidget {
17
const MyApp({super.key});
18
19
@override
20
Widget build(BuildContext context) {
21
return MaterialApp(
22
title: 'Supabase Flutter',
23
theme: ThemeData.dark().copyWith(
24
primaryColor: Colors.green,
25
textButtonTheme: TextButtonThemeData(
26
style: TextButton.styleFrom(
27
foregroundColor: Colors.green,
28
),
29
),
30
elevatedButtonTheme: ElevatedButtonThemeData(
31
style: ElevatedButton.styleFrom(
32
foregroundColor: Colors.white,
33
backgroundColor: Colors.green,
34
),
35
),
36
),
37
home: supabase.auth.currentSession == null
38
? const LoginPage()
39
: const AccountPage(),
40
);
41
}
42
}
43
44
extension ContextExtension on BuildContext {
45
void showSnackBar(String message, {bool isError = false}) {
46
ScaffoldMessenger.of(this).showSnackBar(
47
SnackBar(
48
content: Text(message),
49
backgroundColor: isError
50
? Theme.of(this).colorScheme.error
51
: Theme.of(this).snackBarTheme.backgroundColor,
52
),
53
);
54
}
55
}

完成此操作后,在终端窗口中运行此命令以在 Android 或 iOS 上启动

1
flutter run

对于 Web,运行以下命令在 localhost:3000 上启动它

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

然后在浏览器中打开 localhost:3000,您应该会看到完成的应用。

Supabase User Management example

奖励:个人资料照片#

每个 Supabase 项目都配置了 存储,用于管理大型文件,如照片和视频。

确保我们有一个公共存储桶#

我们将把图片存储为可公开共享的图片。请确保您的 avatars 存储桶设置为公共,如果不是,请通过单击鼠标悬停在存储桶名称上时出现的点菜单来更改公共性。如果您的存储桶设置为公共,您应该在存储桶名称旁边看到一个橙色的 Public 徽章。

向帐户页面添加图片上传功能#

我们将使用 image_picker 插件从设备中选择一张图片。

在 pubspec.yaml 文件中添加以下行以安装 image_picker

1
image_picker: ^1.0.5

使用 image_picker 需要根据平台进行一些额外的准备。请按照 image_picker 的 README.md 中的说明,了解如何在您使用的平台上进行设置。

完成上述所有操作后,就该开始编码了。

创建一个上传小部件#

让我们为用户创建一个头像,以便他们可以上传个人资料照片。我们可以从创建一个新组件开始

1
import 'package:flutter/material.dart';
2
import 'package:image_picker/image_picker.dart';
3
import 'package:supabase_flutter/supabase_flutter.dart';
4
import 'package:supabase_quickstart/main.dart';
5
6
class Avatar extends StatefulWidget {
7
const Avatar({
8
super.key,
9
required this.imageUrl,
10
required this.onUpload,
11
});
12
13
final String? imageUrl;
14
final void Function(String) onUpload;
15
16
@override
17
State<Avatar> createState() => _AvatarState();
18
}
19
20
class _AvatarState extends State<Avatar> {
21
bool _isLoading = false;
22
23
@override
24
Widget build(BuildContext context) {
25
return Column(
26
children: [
27
if (widget.imageUrl == null || widget.imageUrl!.isEmpty)
28
Container(
29
width: 150,
30
height: 150,
31
color: Colors.grey,
32
child: const Center(
33
child: Text('No Image'),
34
),
35
)
36
else
37
Image.network(
38
widget.imageUrl!,
39
width: 150,
40
height: 150,
41
fit: BoxFit.cover,
42
),
43
ElevatedButton(
44
onPressed: _isLoading ? null : _upload,
45
child: const Text('Upload'),
46
),
47
],
48
);
49
}
50
51
Future<void> _upload() async {
52
final picker = ImagePicker();
53
final imageFile = await picker.pickImage(
54
source: ImageSource.gallery,
55
maxWidth: 300,
56
maxHeight: 300,
57
);
58
if (imageFile == null) {
59
return;
60
}
61
setState(() => _isLoading = true);
62
63
try {
64
final bytes = await imageFile.readAsBytes();
65
final fileExt = imageFile.path.split('.').last;
66
final fileName = '${DateTime.now().toIso8601String()}.$fileExt';
67
final filePath = fileName;
68
await supabase.storage.from('avatars').uploadBinary(
69
filePath,
70
bytes,
71
fileOptions: FileOptions(contentType: imageFile.mimeType),
72
);
73
final imageUrlResponse = await supabase.storage
74
.from('avatars')
75
.createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10);
76
widget.onUpload(imageUrlResponse);
77
} on StorageException catch (error) {
78
if (mounted) {
79
context.showSnackBar(error.message, isError: true);
80
}
81
} catch (error) {
82
if (mounted) {
83
context.showSnackBar('Unexpected error occurred', isError: true);
84
}
85
}
86
87
setState(() => _isLoading = false);
88
}
89
}

添加新的小部件#

然后我们可以将小部件添加到帐户页面,并添加一些逻辑,以便在用户上传新头像时更新 avatar_url

1
import 'package:flutter/material.dart';
2
import 'package:supabase_flutter/supabase_flutter.dart';
3
import 'package:supabase_quickstart/components/avatar.dart';
4
import 'package:supabase_quickstart/main.dart';
5
import 'package:supabase_quickstart/pages/login_page.dart';
6
7
class AccountPage extends StatefulWidget {
8
const AccountPage({super.key});
9
10
@override
11
State<AccountPage> createState() => _AccountPageState();
12
}
13
14
class _AccountPageState extends State<AccountPage> {
15
final _usernameController = TextEditingController();
16
final _websiteController = TextEditingController();
17
18
String? _avatarUrl;
19
var _loading = true;
20
21
/// Called once a user id is received within `onAuthenticated()`
22
Future<void> _getProfile() async {
23
setState(() {
24
_loading = true;
25
});
26
27
try {
28
final userId = supabase.auth.currentSession!.user.id;
29
final data =
30
await supabase.from('profiles').select().eq('id', userId).single();
31
_usernameController.text = (data['username'] ?? '') as String;
32
_websiteController.text = (data['website'] ?? '') as String;
33
_avatarUrl = (data['avatar_url'] ?? '') as String;
34
} on PostgrestException catch (error) {
35
if (mounted) context.showSnackBar(error.message, isError: true);
36
} catch (error) {
37
if (mounted) {
38
context.showSnackBar('Unexpected error occurred', isError: true);
39
}
40
} finally {
41
if (mounted) {
42
setState(() {
43
_loading = false;
44
});
45
}
46
}
47
}
48
49
/// Called when user taps `Update` button
50
Future<void> _updateProfile() async {
51
setState(() {
52
_loading = true;
53
});
54
final userName = _usernameController.text.trim();
55
final website = _websiteController.text.trim();
56
final user = supabase.auth.currentUser;
57
final updates = {
58
'id': user!.id,
59
'username': userName,
60
'website': website,
61
'updated_at': DateTime.now().toIso8601String(),
62
};
63
try {
64
await supabase.from('profiles').upsert(updates);
65
if (mounted) context.showSnackBar('Successfully updated profile!');
66
} on PostgrestException catch (error) {
67
if (mounted) context.showSnackBar(error.message, isError: true);
68
} catch (error) {
69
if (mounted) {
70
context.showSnackBar('Unexpected error occurred', isError: true);
71
}
72
} finally {
73
if (mounted) {
74
setState(() {
75
_loading = false;
76
});
77
}
78
}
79
}
80
81
Future<void> _signOut() async {
82
try {
83
await supabase.auth.signOut();
84
} on AuthException catch (error) {
85
if (mounted) context.showSnackBar(error.message, isError: true);
86
} catch (error) {
87
if (mounted) {
88
context.showSnackBar('Unexpected error occurred', isError: true);
89
}
90
} finally {
91
if (mounted) {
92
Navigator.of(context).pushReplacement(
93
MaterialPageRoute(builder: (_) => const LoginPage()),
94
);
95
}
96
}
97
}
98
99
/// Called when image has been uploaded to Supabase storage from within Avatar widget
100
Future<void> _onUpload(String imageUrl) async {
101
try {
102
final userId = supabase.auth.currentUser!.id;
103
await supabase.from('profiles').upsert({
104
'id': userId,
105
'avatar_url': imageUrl,
106
});
107
if (mounted) {
108
const SnackBar(
109
content: Text('Updated your profile image!'),
110
);
111
}
112
} on PostgrestException catch (error) {
113
if (mounted) context.showSnackBar(error.message, isError: true);
114
} catch (error) {
115
if (mounted) {
116
context.showSnackBar('Unexpected error occurred', isError: true);
117
}
118
}
119
if (!mounted) {
120
return;
121
}
122
123
setState(() {
124
_avatarUrl = imageUrl;
125
});
126
}
127
128
@override
129
void initState() {
130
super.initState();
131
_getProfile();
132
}
133
134
@override
135
void dispose() {
136
_usernameController.dispose();
137
_websiteController.dispose();
138
super.dispose();
139
}
140
141
@override
142
Widget build(BuildContext context) {
143
return Scaffold(
144
appBar: AppBar(title: const Text('Profile')),
145
body: ListView(
146
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
147
children: [
148
Avatar(
149
imageUrl: _avatarUrl,
150
onUpload: _onUpload,
151
),
152
const SizedBox(height: 18),
153
TextFormField(
154
controller: _usernameController,
155
decoration: const InputDecoration(labelText: 'User Name'),
156
),
157
const SizedBox(height: 18),
158
TextFormField(
159
controller: _websiteController,
160
decoration: const InputDecoration(labelText: 'Website'),
161
),
162
const SizedBox(height: 18),
163
ElevatedButton(
164
onPressed: _loading ? null : _updateProfile,
165
child: Text(_loading ? 'Saving...' : 'Update'),
166
),
167
const SizedBox(height: 18),
168
TextButton(onPressed: _signOut, child: const Text('Sign Out')),
169
],
170
),
171
);
172
}
173
}

恭喜您,您已经使用 Flutter 和 Supabase 构建了一个功能齐全的用户管理应用程序!

另请参阅#