使用 Flutter 构建用户管理应用
本教程演示如何构建一个基本的用户管理应用。该应用对用户进行身份验证和识别,将他们的个人资料信息存储在数据库中,并允许用户登录、更新他们的个人资料详细信息以及上传个人资料照片。该应用使用
- Supabase 数据库 - 一个用于存储用户数据的 Postgres 数据库,以及 行级别安全,以保护数据并确保用户只能访问他们自己的信息。
- Supabase Auth - 允许用户注册和登录。
- Supabase Storage - 允许用户上传个人资料照片。

如果您在本指南中遇到困难,请参阅 GitHub 上的完整示例。
项目设置#
在开始构建之前,您需要设置数据库和 API。您可以通过在 Supabase 中启动一个新项目,然后在数据库中创建一个“schema”来完成此操作。
创建项目#
- 在 Supabase 控制面板中创建一个新项目。
- 输入您的项目详细信息。
- 等待新的数据库启动。
设置数据库 schema#
现在设置数据库 schema。您可以使用 SQL 编辑器中的“用户管理 Starter”快速入门,或者您可以复制/粘贴下面的 SQL 并运行它。
获取 API 详细信息#
现在您已经创建了一些数据库表,您可以使用自动生成的 API 插入数据。
为此,您需要从项目 连接 对话框中获取项目 URL 和密钥。
API 密钥的更改
Supabase 正在更改密钥的工作方式,以提高项目安全性和开发人员体验。您可以 阅读完整的公告,但在过渡期间,您可以使用当前的 anon 和 service_role 密钥以及新的可发布密钥,格式为 sb_publishable_xxx,它将取代旧的密钥。
在大多数情况下,您可以从项目的 连接 对话框中获取正确的密钥,但如果您想要特定的密钥,可以在项目设置页面的 API 密钥部分中找到所有密钥。
- 对于旧版密钥,从 旧版 API 密钥 选项卡中复制
anon密钥用于客户端操作,并复制service_role密钥用于服务器端操作。 - 对于新密钥,打开 API 密钥 选项卡,如果您还没有可发布密钥,请单击 创建新的 API 密钥,并复制 可发布密钥 部分中的值。
阅读 API 密钥文档 以全面了解所有密钥类型及其用途。
构建应用#
让我们从头开始构建 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 作为深层链接的主机,但您可以将其更改为您想要的任何内容。
首先,在仪表板中将 io.supabase.flutterquickstart://login-callback/ 添加为新的重定向 URL。

Supabase 端就这些了,其余的是平台特定的设置
编辑 ios/Runner/Info.plist 文件。
添加 CFBundleURLTypes 以启用深层链接
1<!-- ... other tags -->2<plist>3<dict>4 <!-- ... other tags -->56 <!-- 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 客户端。这些变量将暴露在应用程序中,这完全没问题,因为我们在数据库中启用了行级安全性。
1import 'package:flutter/material.dart';2import 'package:supabase_flutter/supabase_flutter.dart';34Future<void> main() async {5 await Supabase.initialize(6 url: 'YOUR_SUPABASE_URL',7 anonKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY',8 );9 runApp(const MyApp());10}1112final supabase = Supabase.instance.client;1314class MyApp extends StatelessWidget {15 const MyApp({super.key});1617 @override18 Widget build(BuildContext context) {19 return const MaterialApp(title: 'Supabase Flutter');20 }21}2223extension ContextExtension on BuildContext {24 void showSnackBar(String message, {bool isError = false}) {25 ScaffoldMessenger.of(this).showSnackBar(26 SnackBar(27 content: Text(message),28 backgroundColor: isError29 ? Theme.of(this).colorScheme.error30 : Theme.of(this).snackBarTheme.backgroundColor,31 ),32 );33 }34}请注意,我们有一个 showSnackBar 扩展方法,我们将用它在应用程序中显示 snack bar。您可以将此方法定义在一个单独的文件中,并在需要时导入它,但为了简单起见,我们将在此处定义它。
设置登录页面#
让我们创建一个 Flutter 小部件来管理登录和注册。我们将使用魔术链接,因此用户无需密码即可通过电子邮件登录。
请注意,此页面使用 onAuthStateChange 设置了用户身份验证状态的侦听器。当用户点击魔术链接返回应用时,会触发一个新事件,此页面可以捕获该事件并相应地重定向用户。
1import 'dart:async';23import 'package:flutter/foundation.dart';4import 'package:flutter/material.dart';5import 'package:supabase_flutter/supabase_flutter.dart';6import 'package:supabase_quickstart/main.dart';7import 'package:supabase_quickstart/pages/account_page.dart';89class LoginPage extends StatefulWidget {10 const LoginPage({super.key});1112 @override13 State<LoginPage> createState() => _LoginPageState();14}1516class _LoginPageState extends State<LoginPage> {17 bool _isLoading = false;18 bool _redirecting = false;19 late final TextEditingController _emailController = TextEditingController();20 late final StreamSubscription<AuthState> _authStateSubscription;2122 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!');3435 _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 }5152 @override53 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 }7576 @override77 void dispose() {78 _emailController.dispose();79 _authStateSubscription.cancel();80 super.dispose();81 }8283 @override84 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 的新小部件。
1import 'package:flutter/material.dart';2import 'package:supabase_flutter/supabase_flutter.dart';3import 'package:supabase_quickstart/main.dart';4import 'package:supabase_quickstart/pages/login_page.dart';56class AccountPage extends StatefulWidget {7 const AccountPage({super.key});89 @override10 State<AccountPage> createState() => _AccountPageState();11}1213class _AccountPageState extends State<AccountPage> {14 final _usernameController = TextEditingController();15 final _websiteController = TextEditingController();1617 String? _avatarUrl;18 var _loading = true;1920 /// Called once a user id is received within `onAuthenticated()`21 Future<void> _getProfile() async {22 setState(() {23 _loading = true;24 });2526 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 }4748 /// Called when user taps `Update` button49 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 }7980 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 }9798 @override99 void initState() {100 super.initState();101 _getProfile();102 }103104 @override105 void dispose() {106 _usernameController.dispose();107 _websiteController.dispose();108 super.dispose();109 }110111 @override112 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。如果用户未通过身份验证,MaterialApp 的 home(即向用户显示的初始页面)将是 LoginPage,如果用户已通过身份验证,则为 AccountPage。我们还包含了一些主题,使应用看起来更好看。
1import 'package:flutter/material.dart';2import 'package:supabase_flutter/supabase_flutter.dart';3import 'package:supabase_quickstart/pages/account_page.dart';4import 'package:supabase_quickstart/pages/login_page.dart';56Future<void> main() async {7 await Supabase.initialize(8 url: 'YOUR_SUPABASE_URL',9 anonKey: 'YOUR_SUPABASE_PUBLISHABLE_KEY',10 );11 runApp(const MyApp());12}1314final supabase = Supabase.instance.client;1516class MyApp extends StatelessWidget {17 const MyApp({super.key});1819 @override20 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 == null38 ? const LoginPage()39 : const AccountPage(),40 );41 }42}4344extension ContextExtension on BuildContext {45 void showSnackBar(String message, {bool isError = false}) {46 ScaffoldMessenger.of(this).showSnackBar(47 SnackBar(48 content: Text(message),49 backgroundColor: isError50 ? Theme.of(this).colorScheme.error51 : Theme.of(this).snackBarTheme.backgroundColor,52 ),53 );54 }55}完成此操作后,在终端窗口中运行此命令以在 Android 或 iOS 上启动
1flutter run对于 Web,运行以下命令在 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 中的说明,了解如何在您使用的平台上进行设置。
完成上述所有操作后,就该开始编码了。
创建一个上传小部件#
让我们为用户创建一个头像,以便他们可以上传个人资料照片。我们可以从创建一个新组件开始
1import 'package:flutter/material.dart';2import 'package:image_picker/image_picker.dart';3import 'package:supabase_flutter/supabase_flutter.dart';4import 'package:supabase_quickstart/main.dart';56class Avatar extends StatefulWidget {7 const Avatar({8 super.key,9 required this.imageUrl,10 required this.onUpload,11 });1213 final String? imageUrl;14 final void Function(String) onUpload;1516 @override17 State<Avatar> createState() => _AvatarState();18}1920class _AvatarState extends State<Avatar> {21 bool _isLoading = false;2223 @override24 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 else37 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 }5051 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);6263 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.storage74 .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 }8687 setState(() => _isLoading = false);88 }89}添加新的小部件#
然后我们可以将小部件添加到帐户页面,并添加一些逻辑,以便在用户上传新头像时更新 avatar_url。
1import 'package:flutter/material.dart';2import 'package:supabase_flutter/supabase_flutter.dart';3import 'package:supabase_quickstart/components/avatar.dart';4import 'package:supabase_quickstart/main.dart';5import 'package:supabase_quickstart/pages/login_page.dart';67class AccountPage extends StatefulWidget {8 const AccountPage({super.key});910 @override11 State<AccountPage> createState() => _AccountPageState();12}1314class _AccountPageState extends State<AccountPage> {15 final _usernameController = TextEditingController();16 final _websiteController = TextEditingController();1718 String? _avatarUrl;19 var _loading = true;2021 /// Called once a user id is received within `onAuthenticated()`22 Future<void> _getProfile() async {23 setState(() {24 _loading = true;25 });2627 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 }4849 /// Called when user taps `Update` button50 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 }8081 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 }9899 /// Called when image has been uploaded to Supabase storage from within Avatar widget100 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 }122123 setState(() {124 _avatarUrl = imageUrl;125 });126 }127128 @override129 void initState() {130 super.initState();131 _getProfile();132 }133134 @override135 void dispose() {136 _usernameController.dispose();137 _websiteController.dispose();138 super.dispose();139 }140141 @override142 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 构建了一个功能齐全的用户管理应用程序!