使用Flutter构建用户管理应用
本教程展示如何构建一个基本的用户管理应用程序。该应用程序对用户进行身份验证和识别,将其个人资料信息存储在数据库中,并允许用户登录、更新个人资料详细信息以及上传个人资料照片。该应用程序使用:
- Supabase数据库 - 一个Postgres数据库,用于存储用户数据,并通过行级安全性来保护数据,确保用户只能访问自己的信息。
- Supabase身份验证 - 允许用户注册和登录。
- Supabase存储 - 用户可以上传个人资料照片。
如果在学习本指南时遇到问题,请参考 GitHub上的完整示例。
项目设置
在开始构建之前,我们需要设置数据库和 API。这就像在 Supabase 中创建一个新项目,然后在数据库中创建一个 “模式” 一样简单。
创建项目
- 在 Supabase 仪表板中创建一个新项目。
- 输入项目详细信息。
- 等待新数据库启动。
设置数据库模式
现在我们要设置数据库模式。我们可以在 SQL 编辑器中使用 “用户管理入门” 快速启动模板,或者您也可以直接复制/粘贴下面的 SQL 并自行运行。
获取 API 密钥
既然您已经创建了一些数据库表,就可以使用自动生成的 API 插入数据了。
我们只需要从 API 设置中获取项目 URL 和 anon
密钥。
- 前往仪表板中的 API 设置 页面。
- 在该页面找到您的项目
URL
、anon
和service_role
密钥。
构建应用
让我们从零开始构建Flutter应用。
初始化Flutter应用
我们可以使用 flutter create
命令
初始化一个名为 supabase_quickstart
的应用:
1flutter create supabase_quickstart
然后安装唯一的额外依赖:supabase_flutter
将以下行复制粘贴到您的pubspec.yaml文件中以安装该包:
1supabase_flutter: ^2.0.0
运行 flutter pub get
命令来安装依赖项。
设置深度链接
现在我们已经安装了依赖项,接下来设置深度链接。深度链接用于在用户点击魔法链接登录时将其带回应用程序。只需对Flutter应用程序进行少量调整即可完成设置。
我们需要使用io.supabase.flutterquickstart
作为scheme(方案)。在本示例中,我们将使用login-callback
作为深度链接的host(主机),但您可以将其更改为任意值。
首先,在Dashboard中将io.supabase.flutterquickstart://login-callback/
添加为新的重定向URL。
Supabase端的配置就完成了,其余是平台特定的设置:
编辑ios/Runner/Info.plist
文件。
添加CFBundleURLTypes
以启用深度链接:
1234567891011121314151617181920<!-- ... 其他标签 --><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凭证。这些变量会在应用中公开,这完全没问题,因为我们的数据库已启用行级安全。
12345678910111213141516171819202122232425262728293031323334import '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
设置了用户认证状态的监听器。当用户通过点击魔法链接返回应用时,会触发一个新事件,该页面可以捕获该事件并相应地重定向用户。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105import '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
的新组件。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138import '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
文件。
MaterialApp
的 home
属性决定了用户初始看到的页面:如果用户未认证则显示 LoginPage
,如果已认证则显示 AccountPage
。
我们还添加了一些主题样式让应用看起来更美观。
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455import '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 应用:
1flutter run
如果要运行网页版,使用以下命令在 localhost:3000
启动:
1flutter run -d web-server --web-hostname localhost --web-port 3000
然后在浏览器中打开 localhost:3000,您应该能看到完整的应用。
额外功能:个人资料照片
每个 Supabase 项目都配置了存储服务,用于管理照片和视频等大型文件。
确保拥有公开存储桶
我们将把图片存储为可公开分享的图像。请确保您的 avatars
存储桶设置为公开状态。如果尚未设置,可以通过悬停在存储桶名称上时出现的点菜单来更改公开状态。如果存储桶已设为公开,您会在存储桶名称旁看到一个橙色的 Public
标记。
为账户页面添加图片上传功能
我们将使用 image_picker
插件从设备中选择图片。
在您的 pubspec.yaml 文件中添加以下行来安装 image_picker
:
1image_picker: ^1.0.5
使用 image_picker
需要根据平台进行一些额外准备。请按照 image_picker
的 README.md 中的说明,为您的使用平台进行设置。
完成以上所有步骤后,就可以开始编写代码了。
创建上传组件
让我们为用户创建一个头像组件,使他们能够上传个人资料照片。 我们可以从创建一个新组件开始:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889import '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
的逻辑。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173import '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构建了一个功能完整的用户管理应用!