快速开始

使用Flutter构建用户管理应用


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

Supabase 用户管理示例

项目设置

在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。

创建项目

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

设置数据库模式

现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。

  1. 转到仪表板中的SQL 编辑器页面。
  2. 点击 用户管理入门
  3. 点击 运行
1
2
3
4
supabase link --project-ref <project-id># 您可以从项目的仪表板 URL 获取 <project-id>:https://supabase.com/dashboard/project/<project-id>supabase db pull

获取 API 密钥

既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。 我们只需要从 API 设置中获取项目 URL 和 anon 密钥。

  1. 前往仪表板中的 API 设置 页面。
  2. 在该页面找到您的项目 URLanonservice_role 密钥。

构建应用

让我们从零开始构建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作为深度链接的host(主机),但您可以将其更改为任意值。

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

Supabase控制台深度链接设置

Supabase端的配置就完成了,其余是平台特定的设置:

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

添加CFBundleURLTypes以启用深度链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- ... 其他标签 --><plist><dict> <!-- ... 其他标签 --> <!-- 添加此数组用于深度链接 --> <key>CFBundleURLTypes</key> <array> <dict> <key>CFBundleTypeRole</key> <string>Editor</string> <key>CFBundleURLSchemes</key> <array> <string>io.supabase.flutterquickstart</string> </array> </dict> </array> <!-- ... 其他标签 --></dict></plist>

主函数

现在我们已经准备好了深度链接,让我们在main函数中初始化Supabase客户端,使用您之前获取的API凭证。这些变量会在应用中公开,这完全没问题,因为我们的数据库已启用行级安全

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

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

设置登录页面

让我们创建一个Flutter widget来管理登录和注册。我们将使用Magic Links(魔法链接),这样用户无需密码即可通过电子邮件登录。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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';import 'package:supabase_quickstart/pages/account_page.dart';class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State<LoginPage> 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.supabase.flutterquickstart://login-callback/', ); if (mounted) { context.showSnackBar('请检查您的邮箱获取登录链接!'); _emailController.clear(); } } on AuthException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } 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).pushReplacement( MaterialPageRoute(builder: (context) => const AccountPage()), ); } }, onError: (error) { if (error is AuthException) { context.showSnackBar(error.message, isError: true); } else { context.showSnackBar('发生意外错误', isError: true); } }, ); super.initState(); } @override void dispose() { _emailController.dispose(); _authStateSubscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('登录')), body: ListView( padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12), children: [ const Text('通过下方邮箱接收魔法链接登录'), const SizedBox(height: 18), TextFormField( controller: _emailController, decoration: const InputDecoration(labelText: '邮箱'), ), const SizedBox(height: 18), ElevatedButton( onPressed: _isLoading ? null : _signIn, child: Text(_isLoading ? '发送中...' : '发送魔法链接'), ), ], ), ); }}

设置账户页面

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import 'package:flutter/material.dart';import 'package:supabase_flutter/supabase_flutter.dart';import 'package:supabase_quickstart/main.dart';import 'package:supabase_quickstart/pages/login_page.dart';class AccountPage extends StatefulWidget { const AccountPage({super.key}); @override State<AccountPage> createState() => _AccountPageState();}class _AccountPageState extends State<AccountPage> { final _usernameController = TextEditingController(); final _websiteController = TextEditingController(); String? _avatarUrl; var _loading = true; /// 在 `onAuthenticated()` 中获取到用户ID后调用 Future<void> _getProfile() async { setState(() { _loading = true; }); try { final userId = supabase.auth.currentSession!.user.id; final data = await supabase.from('profiles').select().eq('id', userId).single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; _avatarUrl = (data['avatar_url'] ?? '') as String; } on PostgrestException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { setState(() { _loading = false; }); } } } /// 当用户点击`更新`按钮时调用 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) context.showSnackBar('个人资料更新成功!'); } on PostgrestException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { setState(() { _loading = false; }); } } } Future<void> _signOut() async { try { await supabase.auth.signOut(); } on AuthException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const LoginPage()), ); } } } @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('个人资料')), body: ListView( padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12), children: [ TextFormField( controller: _usernameController, decoration: const InputDecoration(labelText: '用户名'), ), const SizedBox(height: 18), TextFormField( controller: _websiteController, decoration: const InputDecoration(labelText: '网站'), ), const SizedBox(height: 18), ElevatedButton( onPressed: _loading ? null : _updateProfile, child: Text(_loading ? '保存中...' : '更新'), ), const SizedBox(height: 18), TextButton(onPressed: _signOut, child: const Text('退出登录')), ], ), ); }}

启动应用!

现在我们已经准备好了所有组件,让我们更新 lib/main.dart 文件。 MaterialApphome 属性决定了用户初始看到的页面:如果用户未认证则显示 LoginPage,如果已认证则显示 AccountPage。 我们还添加了一些主题样式让应用看起来更美观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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';Future<void> main() async { await Supabase.initialize( url: 'YOUR_SUPABASE_URL', anonKey: 'YOUR_SUPABASE_ANON_KEY', ); runApp(const MyApp());}final supabase = Supabase.instance.client;class MyApp extends StatelessWidget { const MyApp({super.key}); @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, ), ), ), home: supabase.auth.currentSession == null ? const LoginPage() : const AccountPage(), ); }}extension ContextExtension on BuildContext { void showSnackBar(String message, {bool isError = false}) { ScaffoldMessenger.of(this).showSnackBar( SnackBar( content: Text(message), backgroundColor: isError ? Theme.of(this).colorScheme.error : Theme.of(this).snackBarTheme.backgroundColor, ), ); }}

完成后,在终端窗口运行以下命令启动 Android 或 iOS 应用:

1
flutter run

如果要运行网页版,使用以下命令在 localhost:3000 启动:

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

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

Supabase 用户管理示例

额外功能:个人资料照片

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

确保拥有公开存储桶

我们将把图片存储为可公开分享的图像。请确保您的 avatars 存储桶设置为公开状态。如果尚未设置,可以通过悬停在存储桶名称上时出现的点菜单来更改公开状态。如果存储桶已设为公开,您会在存储桶名称旁看到一个橙色的 Public 标记。

为账户页面添加图片上传功能

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

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

1
image_picker: ^1.0.5

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

完成以上所有步骤后,就可以开始编写代码了。

创建上传组件

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

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

添加新组件

接下来我们可以在账户页面添加这个组件,并实现当用户上传新头像时更新 avatar_url 的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import 'package:flutter/material.dart';import 'package:supabase_flutter/supabase_flutter.dart';import 'package:supabase_quickstart/components/avatar.dart';import 'package:supabase_quickstart/main.dart';import 'package:supabase_quickstart/pages/login_page.dart';class AccountPage extends StatefulWidget { const AccountPage({super.key}); @override State<AccountPage> createState() => _AccountPageState();}class _AccountPageState extends State<AccountPage> { final _usernameController = TextEditingController(); final _websiteController = TextEditingController(); String? _avatarUrl; var _loading = true; /// 在 `onAuthenticated()` 中获取到用户ID后调用 Future<void> _getProfile() async { setState(() { _loading = true; }); try { final userId = supabase.auth.currentSession!.user.id; final data = await supabase.from('profiles').select().eq('id', userId).single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; _avatarUrl = (data['avatar_url'] ?? '') as String; } on PostgrestException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { setState(() { _loading = false; }); } } } /// 当用户点击`更新`按钮时调用 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) context.showSnackBar('个人资料更新成功!'); } on PostgrestException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { setState(() { _loading = false; }); } } } Future<void> _signOut() async { try { await supabase.auth.signOut(); } on AuthException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } finally { if (mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (_) => const LoginPage()), ); } } } /// 当头像组件上传图片到Supabase存储后调用 Future<void> _onUpload(String imageUrl) async { try { final userId = supabase.auth.currentUser!.id; await supabase.from('profiles').upsert({ 'id': userId, 'avatar_url': imageUrl, }); if (mounted) { const SnackBar( content: Text('已更新您的个人头像!'), ); } } on PostgrestException catch (error) { if (mounted) context.showSnackBar(error.message, isError: true); } catch (error) { if (mounted) { context.showSnackBar('发生意外错误', isError: true); } } if (!mounted) { return; } setState(() { _avatarUrl = imageUrl; }); } @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('个人资料')), 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: '用户名'), ), const SizedBox(height: 18), TextFormField( controller: _websiteController, decoration: const InputDecoration(labelText: '网站'), ), const SizedBox(height: 18), ElevatedButton( onPressed: _loading ? null : _updateProfile, child: Text(_loading ? '保存中...' : '更新'), ), const SizedBox(height: 18), TextButton(onPressed: _signOut, child: const Text('退出登录')), ], ), ); }}

恭喜!您已经使用Flutter和Supabase构建了一个功能完整的用户管理应用!

相关阅读