mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
24be387cdb
## What kind of change does this PR introduce? Docs update. Aligns documentation and style guides with the **Sign in / Sign out / Sign up** platform standard. Closes DOCS-1328. Related to [#49874](https://github.com/supabase/supabase/pull/49874). ## What is the current behavior? Docs style guides prefer _login_ / _log in_. Guide prose uses mixed login and sign in wording. ## What is the new behavior? - [WORD_LIST.md](apps/docs/WORD_LIST.md) and [copywriting.mdx](apps/design-system/content/docs/copywriting.mdx) document the sign in standard - Design-system auth examples updated - Guide prose and API reference spec descriptions updated ### Terminology **Standard:** Use _sign in_, _sign out_, and _sign up_ as verbs. Use _sign-in_, _sign-out_, and _sign-up_ as nouns and adjectives. Match Studio UI labels (**Sign in**, **Sign out**, **Sign up**). **Preserved intentionally:** | Category | Keep as-is | Example | | -------- | ---------- | ------- | | Feature name | social login | `/social-login`, `features.mdx` heading, OAuth provider section | | URL slugs | `login` in paths | `/phone-login`, `/login-flows`, `choosing-login-flow` | | CLI | `supabase login` / `supabase logout` | Reference ids `supabase-login` / `supabase-logout`; executable commands unchanged | | SDK methods | `logout()` | Kotlin/Swift method names in API reference titles and examples | | Third-party UI | Provider product labels | Facebook Login, Kakao Login, portal **Login** buttons | | Postgres | Database terminology | login privileges, login credentials, login via role | | Audit/logging | Log prose | "Generates the following **log** in the Postgres Logs" | | Code and routes | Paths and filenames | `app/login/`, `Login.tsx`, `demos/android-login` | | External URLs | Third-party login pages | `dash.cloudflare.com/login`, `console.neon.tech/login`, `vercel.com/login` | | API identifiers | Event and field names | Audit actions `login`/`logout`, `should_logout_user` | ## To test - Run `pnpm lint:mdx` in `apps/docs` - Spot-check `features.mdx`, `social-login.mdx`, and a provider guide (e.g. Facebook, Kakao) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Documentation** * Standardized authentication terminology across guides, reference material, CLI documentation, and copywriting guidance using “sign in,” “sign out,” and “sign up.” * Updated authentication instructions, headings, link text, examples, and SSO guidance for clearer, more consistent wording. * Corrected related grammar, spelling, hyphenation, and documentation links while preserving established product names and implementation commands. * **Style** * Refined code examples with consistent import ordering and spacing. * **Examples** * Updated authentication button and menu labels to “Sign in” and “Sign out.” <!-- end of auto-generated comment: release notes by coderabbit.ai -->
861 lines
24 KiB
Plaintext
861 lines
24 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Flutter'
|
|
description: 'Learn how to use Supabase in your Flutter App.'
|
|
tocVideo: 'r7ysVtZ5Row'
|
|
---
|
|
|
|
<$Partial path="quickstart_intro.mdx" />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/flutter-user-management).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" variables={{ "framework": "flutter", "tab": "mobiles" }} />
|
|
|
|
## Building the app
|
|
|
|
Build the Flutter app from scratch.
|
|
|
|
### Initialize a Flutter app
|
|
|
|
We can use [`flutter create`](https://flutter.dev/docs/get-started/test-drive) to initialize
|
|
an app called `supabase_quickstart`:
|
|
|
|
```bash
|
|
flutter create supabase_quickstart
|
|
```
|
|
|
|
Then install the only additional dependency: [`supabase_flutter`](https://pub.dev/packages/supabase_flutter)
|
|
|
|
Copy and paste the following line in your pubspec.yaml to install the package:
|
|
|
|
```yaml
|
|
supabase_flutter: ^2.0.0
|
|
```
|
|
|
|
Run `flutter pub get` to install the dependencies.
|
|
|
|
### Setup deep links
|
|
|
|
With dependencies installed, set up 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 a minor tweak on our Flutter application.
|
|
|
|
We have to use `io.supabase.flutterquickstart` as the scheme. In this example, we will use `login-callback` as the host for our deep link, but you can change it to whatever you would like.
|
|
|
|
First, add `io.supabase.flutterquickstart://login-callback/` as a new [redirect URL](/dashboard/project/_/auth/url-configuration) in the Dashboard.
|
|
|
|

|
|
|
|
That is it on Supabase's end and the rest are platform specific settings:
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="ios"
|
|
queryGroup="platform"
|
|
>
|
|
<TabPanel id="ios" label="iOS">
|
|
Edit the `ios/Runner/Info.plist` file.
|
|
|
|
Add `CFBundleURLTypes` to enable deep linking:
|
|
|
|
<$CodeTabs>
|
|
|
|
```xml name=ios/Runner/Info.plist
|
|
<!-- ... other tags -->
|
|
<plist>
|
|
<dict>
|
|
<!-- ... other tags -->
|
|
|
|
<!-- Add this array for Deep Links -->
|
|
<key>CFBundleURLTypes</key>
|
|
<array>
|
|
<dict>
|
|
<key>CFBundleTypeRole</key>
|
|
<string>Editor</string>
|
|
<key>CFBundleURLSchemes</key>
|
|
<array>
|
|
<string>io.supabase.flutterquickstart</string>
|
|
</array>
|
|
</dict>
|
|
</array>
|
|
<!-- ... other tags -->
|
|
</dict>
|
|
</plist>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
</TabPanel>
|
|
<TabPanel id="android" label="Android">
|
|
Edit the `android/app/src/main/AndroidManifest.xml` file.
|
|
|
|
Add an intent-filter to enable deep linking:
|
|
|
|
<$CodeTabs>
|
|
|
|
```xml name=android/app/src/main/AndroidManifest.xml
|
|
<manifest ...>
|
|
<!-- ... other tags -->
|
|
<application ...>
|
|
<activity ...>
|
|
<!-- ... other tags -->
|
|
|
|
<!-- Add this intent-filter for Deep Links -->
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
|
|
<data
|
|
android:scheme="io.supabase.flutterquickstart"
|
|
android:host="login-callback" />
|
|
</intent-filter>
|
|
|
|
</activity>
|
|
</application>
|
|
</manifest>
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
</TabPanel>
|
|
<TabPanel id="web" label="Web">
|
|
Supabase redirects do not work with Flutter's [default URL strategy](https://docs.flutter.dev/ui/navigation/url-strategies).
|
|
We can switch to the path URL strategy as follows:
|
|
|
|
```dart
|
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
|
|
|
void main() {
|
|
usePathUrlStrategy();
|
|
runApp(ExampleApp());
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
</Tabs>
|
|
|
|
### Main function
|
|
|
|
With deep links configured, initialize the Supabase client inside the `main` function with the API credentials that you copied [earlier](#get-the-api-keys). These variables will be exposed on the app, and that's completely fine since we have [Row Level Security](/docs/guides/auth#row-level-security) enabled on our Database.
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=lib/main.dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
Future<void> main() async {
|
|
await Supabase.initialize(
|
|
url: 'YOUR_SUPABASE_URL',
|
|
publishableKey: 'YOUR_SUPABASE_PUBLISHABLE_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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Notice that we have a `showSnackBar` extension method that we will use to show snack bars in the app. You could define this method in a separate file and import it where needed, but for simplicity, we will define it here.
|
|
|
|
### Set up a sign-in page
|
|
|
|
Create a Flutter widget to manage sign-ins and sign-ups. We will use Magic Links, so users can sign in with their email without using passwords.
|
|
|
|
Notice that this page sets up a listener on the user's auth state using `onAuthStateChange`. A new event will fire when the user comes back to the app by clicking their magic link, which this page can catch and redirect the user accordingly.
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=lib/pages/login_page.dart
|
|
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('Check your email for a login link!');
|
|
|
|
_emailController.clear();
|
|
}
|
|
} on AuthException catch (error) {
|
|
if (mounted) context.showSnackBar(error.message, isError: true);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
context.showSnackBar('Unexpected error occurred', 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('Unexpected error occurred', 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('Sign In')),
|
|
body: ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
|
|
children: [
|
|
const Text('Sign in via the magic link with your email below'),
|
|
const SizedBox(height: 18),
|
|
TextFormField(
|
|
controller: _emailController,
|
|
decoration: const InputDecoration(labelText: 'Email'),
|
|
),
|
|
const SizedBox(height: 18),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _signIn,
|
|
child: Text(_isLoading ? 'Sending...' : 'Send Magic Link'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Set up account page
|
|
|
|
After a user is signed in we can allow them to edit their profile details and manage their account.
|
|
Create a new widget called `account_page.dart`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=lib/pages/account_page.dart
|
|
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;
|
|
|
|
/// Called once a user id is received within `onAuthenticated()`
|
|
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('Unexpected error occurred', isError: true);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Called when user taps `Update` button
|
|
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('Successfully updated profile!');
|
|
} on PostgrestException catch (error) {
|
|
if (mounted) context.showSnackBar(error.message, isError: true);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
context.showSnackBar('Unexpected error occurred', 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('Unexpected error occurred', 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('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: _loading ? null : _updateProfile,
|
|
child: Text(_loading ? 'Saving...' : 'Update'),
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextButton(onPressed: _signOut, child: const Text('Sign Out')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Launch!
|
|
|
|
With all components in place, update `lib/main.dart`.
|
|
The `home` of the `MaterialApp`, meaning the initial page shown to the user, will be the `LoginPage` if the user is not authenticated, and the `AccountPage` if the user is authenticated.
|
|
We also included some theming to make the app look a bit nicer.
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=lib/main.dart
|
|
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',
|
|
publishableKey: 'YOUR_SUPABASE_PUBLISHABLE_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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Once that's done, run this in a terminal window to launch on Android or iOS:
|
|
|
|
```bash
|
|
flutter run
|
|
```
|
|
|
|
Or for web, run the following command to launch it on `localhost:3000`
|
|
|
|
```bash
|
|
flutter run -d web-server --web-hostname localhost --web-port 3000
|
|
```
|
|
|
|
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
|
|
|

|
|
|
|
## Bonus: Profile photos
|
|
|
|
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
|
|
photos and videos.
|
|
|
|
### Making sure we have a public bucket
|
|
|
|
We will be storing the image as a publicly sharable image.
|
|
Make sure your `avatars` bucket is set to public, and if it is not, change the publicity by clicking the dot menu that appears when you hover over the bucket name.
|
|
You should see an orange `Public` badge next to your bucket name if your bucket is set to public.
|
|
|
|
### Adding image uploading feature to account page
|
|
|
|
We will use [`image_picker`](https://pub.dev/packages/image_picker) plugin to select an image from the device.
|
|
|
|
Add the following line in your pubspec.yaml file to install `image_picker`:
|
|
|
|
```yaml
|
|
image_picker: ^1.0.5
|
|
```
|
|
|
|
Using [`image_picker`](https://pub.dev/packages/image_picker) requires some additional preparation depending on the platform.
|
|
Follow the instruction on README.md of [`image_picker`](https://pub.dev/packages/image_picker) on how to set it up for the platform you are using.
|
|
|
|
Once you are done with all of the above, it is time to dive into coding.
|
|
|
|
### Create an upload widget
|
|
|
|
Create an avatar so the user can upload a profile photo.
|
|
We can start by creating a new component:
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=lib/components/avatar.dart
|
|
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('No Image'),
|
|
),
|
|
)
|
|
else
|
|
Image.network(
|
|
widget.imageUrl!,
|
|
width: 150,
|
|
height: 150,
|
|
fit: BoxFit.cover,
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _upload,
|
|
child: const Text('Upload'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
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('Unexpected error occurred', isError: true);
|
|
}
|
|
}
|
|
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the Account page as well as some logic to update the `avatar_url` whenever the user uploads a new avatar.
|
|
|
|
<$CodeTabs>
|
|
|
|
```dart name=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/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;
|
|
|
|
/// Called once a user id is received within `onAuthenticated()`
|
|
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('Unexpected error occurred', isError: true);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Called when user taps `Update` button
|
|
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('Successfully updated profile!');
|
|
} on PostgrestException catch (error) {
|
|
if (mounted) context.showSnackBar(error.message, isError: true);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
context.showSnackBar('Unexpected error occurred', 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('Unexpected error occurred', isError: true);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => const LoginPage()),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Called when image has been uploaded to Supabase storage from within Avatar widget
|
|
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('Updated your profile image!'),
|
|
);
|
|
}
|
|
} on PostgrestException catch (error) {
|
|
if (mounted) context.showSnackBar(error.message, isError: true);
|
|
} catch (error) {
|
|
if (mounted) {
|
|
context.showSnackBar('Unexpected error occurred', 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('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: _loading ? null : _updateProfile,
|
|
child: Text(_loading ? 'Saving...' : 'Update'),
|
|
),
|
|
const SizedBox(height: 18),
|
|
TextButton(onPressed: _signOut, child: const Text('Sign Out')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Congratulations, you've built a fully functional user management app using Flutter and Supabase!
|
|
|
|
## See also
|
|
|
|
- [Flutter Tutorial: building a Flutter chat app](/blog/flutter-tutorial-building-a-chat-app)
|
|
- [Flutter Tutorial - Part 2: Authentication and Authorization with RLS](/blog/flutter-authentication-and-authorization-with-rls)
|