From ee83945d53cc4f72ea31ddf11955637f6ddf52b4 Mon Sep 17 00:00:00 2001 From: dshukertjr <18113850+dshukertjr@users.noreply.github.com> Date: Tue, 13 Jun 2023 14:26:39 +0900 Subject: [PATCH] guide: update flutter user management guide with new code --- .../tutorials/with-flutter.mdx | 352 ++++++++++-------- .../flutter-user-management/lib/main.dart | 1 + 2 files changed, 207 insertions(+), 146 deletions(-) diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx index 2978ec82638..72792b7d1c2 100644 --- a/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx +++ b/apps/docs/pages/guides/getting-started/tutorials/with-flutter.mdx @@ -34,16 +34,20 @@ Then let's install the only additional dependency: [`supabase_flutter`](https:// Copy and paste the following line in your pubspec.yaml to install the package: ```yaml -supabase_flutter: ^1.0.0 +supabase_flutter: ^1.10.3 ``` Run `flutter pub get` to install the dependencies. ### Setup deep links -Now that we have the dependencies installed let's setup deep links so users who have logged in via magic link or OAuth can come back to the app. +Now that we have the dependencies installed let's setup deep links. +Setting up deep links is required to bring back the user to the app when they click on the magic link to sign in. +We can setup deep links with just a minor tweak on our Flutter application. -Add `io.supabase.flutterquickstart://login-callback/` as a new [redirect URL](https://app.supabase.com/project/_/auth/url-configuration) in the Dashboard. +We will use `io.supabase.flutterquickstart` as the scheme, and `login-callback` as the host for our deep link in this example, but you can change it to whatever you would like. + +First, add `io.supabase.flutterquickstart://login-callback/` as a new [redirect URL](https://app.supabase.com/project/_/auth/url-configuration) in the Dashboard. ![Supabase console deep link setting](/docs/img/deeplink-setting.png) @@ -115,44 +119,19 @@ These variables will be exposed on the app, and that's completely fine since we ```dart lib/main.dart Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - await Supabase.initialize( url: 'YOUR_SUPABASE_URL', anonKey: 'YOUR_SUPABASE_ANON_KEY', + authFlowType: AuthFlowType.pkce, ); runApp(MyApp()); } -``` - -### Setting up some constants and handy functions - -Let's also create a constant file to make it easier to use Supabase client. -We will also include an extension method declaration to call `showSnackBar` with one line of code. - -```dart lib/constants.dart -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); - } -} ``` +`AuthFlowType.pkce` on `authFlowType` parameter indicates that we are using a secure [PKCE flow](https://supabase.com/blog/supabase-auth-sso-pkce#introducing-pkce) to perform our magic link login. + ### Set up Splash Screen Let's create a splash screen that will be shown to users right after they open the app. @@ -160,7 +139,7 @@ This screen retrieves the current session and redirects the user accordingly. ```dart lib/pages/splash_page.dart import 'package:flutter/material.dart'; -import 'package:supabase_quickstart/constants.dart'; +import 'package:supabase_quickstart/main.dart'; class SplashPage extends StatefulWidget { const SplashPage({super.key}); @@ -199,7 +178,6 @@ class _SplashPageState extends State { ); } } - ``` ### Set up a Login page @@ -215,8 +193,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; - -import 'package:supabase_quickstart/constants.dart'; +import 'package:supabase_quickstart/main.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -228,37 +205,46 @@ class LoginPage extends StatefulWidget { class _LoginPageState extends State { bool _isLoading = false; bool _redirecting = false; - late final TextEditingController _emailController; + late final TextEditingController _emailController = TextEditingController(); late final StreamSubscription _authStateSubscription; Future _signIn() async { - setState(() { - _isLoading = true; - }); try { + setState(() { + _isLoading = true; + }); await supabase.auth.signInWithOtp( email: _emailController.text.trim(), emailRedirectTo: kIsWeb ? null : 'io.supabase.flutterquickstart://login-callback/', ); if (mounted) { - context.showSnackBar(message: 'Check your email for login link!'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Check your email for a login link!')), + ); _emailController.clear(); } } on AuthException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected error occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } } - - setState(() { - _isLoading = false; - }); } @override void initState() { - _emailController = TextEditingController(); _authStateSubscription = supabase.auth.onAuthStateChange.listen((data) { if (_redirecting) return; final session = data.session; @@ -310,8 +296,7 @@ Let's create a new widget called `account_page.dart` for that. ```dart lib/pages/account_page.dart" import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:supabase_quickstart/components/avatar.dart'; -import 'package:supabase_quickstart/constants.dart'; +import 'package:supabase_quickstart/main.dart'; class AccountPage extends StatefulWidget { const AccountPage({super.key}); @@ -323,8 +308,8 @@ class AccountPage extends StatefulWidget { class _AccountPageState extends State { final _usernameController = TextEditingController(); final _websiteController = TextEditingController(); - String? _avatarUrl; - var _loading = false; + + var _loading = true; /// Called once a user id is received within `onAuthenticated()` Future _getProfile() async { @@ -336,21 +321,28 @@ class _AccountPageState extends State { final userId = supabase.auth.currentUser!.id; final data = await supabase .from('profiles') - .select() + .select>() .eq('id', userId) - .single() as Map; + .single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; - _avatarUrl = (data['avatar_url'] ?? '') as String; } on PostgrestException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected exception occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } } - - setState(() { - _loading = false; - }); } /// Called when user taps `Update` button @@ -370,28 +362,46 @@ class _AccountPageState extends State { try { await supabase.from('profiles').upsert(updates); if (mounted) { - context.showSnackBar(message: 'Successfully updated profile!'); + const SnackBar( + content: Text('Successfully updated profile!'), + ); } } on PostgrestException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpeted error occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } } - setState(() { - _loading = false; - }); } Future _signOut() async { try { await supabase.auth.signOut(); } on AuthException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected error occurred'); - } - if (mounted) { - Navigator.of(context).pushReplacementNamed('/'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + Navigator.of(context).pushReplacementNamed('/login'); + } } } @@ -412,27 +422,29 @@ class _AccountPageState extends State { 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), - TextButton(onPressed: _signOut, child: const Text('Sign Out')), - ], - ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : 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: _loading ? null : _updateProfile, + child: Text(_loading ? 'Saving...' : 'Update'), + ), + const SizedBox(height: 18), + TextButton(onPressed: _signOut, child: const Text('Sign Out')), + ], + ), ); } } @@ -451,13 +463,14 @@ import 'package:supabase_quickstart/pages/splash_page.dart'; Future main() async { await Supabase.initialize( - // TODO: Replace credentials with your own url: 'YOUR_SUPABASE_URL', anonKey: 'YOUR_SUPABASE_ANON_KEY', ); runApp(MyApp()); } +final supabase = Supabase.instance.client; + class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { @@ -539,7 +552,7 @@ We can start by creating a new component: import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:supabase_quickstart/constants.dart'; +import 'package:supabase_quickstart/main.dart'; class Avatar extends StatefulWidget { const Avatar({ @@ -614,11 +627,21 @@ class _AvatarState extends State { widget.onUpload(imageUrlResponse); } on StorageException catch (error) { if (mounted) { - context.showErrorSnackBar(message: error.message); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); } } catch (error) { if (mounted) { - context.showErrorSnackBar(message: 'Unexpected error occurred'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); } } @@ -635,7 +658,7 @@ And then we can add the widget to the Account page as well as some logic to upda import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_quickstart/components/avatar.dart'; -import 'package:supabase_quickstart/constants.dart'; +import 'package:supabase_quickstart/main.dart'; class AccountPage extends StatefulWidget { const AccountPage({super.key}); @@ -647,8 +670,9 @@ class AccountPage extends StatefulWidget { class _AccountPageState extends State { final _usernameController = TextEditingController(); final _websiteController = TextEditingController(); + String? _avatarUrl; - var _loading = false; + var _loading = true; /// Called once a user id is received within `onAuthenticated()` Future _getProfile() async { @@ -660,21 +684,29 @@ class _AccountPageState extends State { final userId = supabase.auth.currentUser!.id; final data = await supabase .from('profiles') - .select() + .select>() .eq('id', userId) - .single() as Map; + .single(); _usernameController.text = (data['username'] ?? '') as String; _websiteController.text = (data['website'] ?? '') as String; _avatarUrl = (data['avatar_url'] ?? '') as String; } on PostgrestException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected exception occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } } - - setState(() { - _loading = false; - }); } /// Called when user taps `Update` button @@ -694,28 +726,46 @@ class _AccountPageState extends State { try { await supabase.from('profiles').upsert(updates); if (mounted) { - context.showSnackBar(message: 'Successfully updated profile!'); + const SnackBar( + content: Text('Successfully updated profile!'), + ); } } on PostgrestException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpeted error occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + setState(() { + _loading = false; + }); + } } - setState(() { - _loading = false; - }); } Future _signOut() async { try { await supabase.auth.signOut(); } on AuthException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected error occurred'); - } - if (mounted) { - Navigator.of(context).pushReplacementNamed('/'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); + } finally { + if (mounted) { + Navigator.of(context).pushReplacementNamed('/login'); + } } } @@ -728,12 +778,20 @@ class _AccountPageState extends State { 'avatar_url': imageUrl, }); if (mounted) { - context.showSnackBar(message: 'Updated your profile image!'); + const SnackBar( + content: Text('Updated your profile image!'), + ); } } on PostgrestException catch (error) { - context.showErrorSnackBar(message: error.message); + SnackBar( + content: Text(error.message), + backgroundColor: Theme.of(context).colorScheme.error, + ); } catch (error) { - context.showErrorSnackBar(message: 'Unexpected error has occurred'); + SnackBar( + content: const Text('Unexpected error occurred'), + backgroundColor: Theme.of(context).colorScheme.error, + ); } if (!mounted) { return; @@ -761,32 +819,34 @@ class _AccountPageState extends State { 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), - TextButton(onPressed: _signOut, child: const Text('Sign Out')), - ], - ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : 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: _loading ? null : _updateProfile, + child: Text(_loading ? 'Saving...' : 'Update'), + ), + const SizedBox(height: 18), + TextButton(onPressed: _signOut, child: const Text('Sign Out')), + ], + ), ); } } diff --git a/examples/user-management/flutter-user-management/lib/main.dart b/examples/user-management/flutter-user-management/lib/main.dart index 656b7e22257..8b5a5969a24 100644 --- a/examples/user-management/flutter-user-management/lib/main.dart +++ b/examples/user-management/flutter-user-management/lib/main.dart @@ -9,6 +9,7 @@ Future main() async { // TODO: Replace credentials with your own url: 'YOUR_SUPABASE_URL', anonKey: 'YOUR_SUPABASE_ANON_KEY', + authFlowType: AuthFlowType.pkce, ); runApp(MyApp()); }