diff --git a/.github/workflows/title-validation.yml b/.github/workflows/title-validation.yml index 87bedfb3..7e938285 100644 --- a/.github/workflows/title-validation.yml +++ b/.github/workflows/title-validation.yml @@ -1,5 +1,5 @@ # See https://github.com/amannn/action-semantic-pull-request -name: 'PR Title is Conventional' +name: "PR Title is Conventional" on: pull_request: @@ -33,6 +33,3 @@ jobs: revert style test - subjectPattern: ^[A-Z].+$ - subjectPatternError: | - The subject of the PR must begin with an uppercase letter. diff --git a/packages/supabase_flutter/example/FACEBOOK_AUTH_SETUP.md b/packages/supabase_flutter/example/FACEBOOK_AUTH_SETUP.md new file mode 100644 index 00000000..9c59649c --- /dev/null +++ b/packages/supabase_flutter/example/FACEBOOK_AUTH_SETUP.md @@ -0,0 +1,98 @@ +# Facebook Authentication Setup + +This guide explains how to set up Facebook authentication in the Supabase Examples app. + +## Prerequisites + +1. A Facebook Developer account +2. A Facebook App created in the Facebook Developer Console +3. Supabase project with Facebook OAuth configured + +## Setup Steps + +### 1. Facebook Developer Console Configuration + +1. Go to [Facebook Developers](https://developers.facebook.com/) +2. Create a new app or use an existing one +3. Add "Facebook Login" product to your app +4. Configure your OAuth redirect URIs in Facebook Login settings: + - Add your Supabase project's Facebook OAuth callback URL + - Format: `https://[your-project-ref].supabase.co/auth/v1/callback` + +### 2. Supabase Configuration + +1. Go to your Supabase project dashboard +2. Navigate to Authentication > Providers +3. Enable Facebook provider +4. Enter your Facebook App ID and App Secret +5. Configure the redirect URL if needed + +### 3. Flutter App Configuration + +#### Android Configuration + +The following files have been configured for you: + +1. **AndroidManifest.xml** - Contains Facebook SDK configuration +2. **strings.xml** - Contains placeholder values for Facebook credentials + +You need to update the following values in `android/app/src/main/res/values/strings.xml`: + +```xml +YOUR_ACTUAL_FACEBOOK_APP_ID +YOUR_ACTUAL_FACEBOOK_CLIENT_TOKEN +fbYOUR_ACTUAL_FACEBOOK_APP_ID +``` + +#### iOS Configuration + +The following files have been configured for you: + +1. **Info.plist** - Contains Facebook SDK configuration + +You need to update the following values in `ios/Runner/Info.plist`: + +```xml +FacebookAppID +YOUR_ACTUAL_FACEBOOK_APP_ID +FacebookClientToken +YOUR_ACTUAL_FACEBOOK_CLIENT_TOKEN +``` + +And update the URL scheme: +```xml +fbYOUR_ACTUAL_FACEBOOK_APP_ID +``` + +## Getting Your Facebook Credentials + +### App ID +1. Go to Facebook Developers Console +2. Select your app +3. Go to Settings > Basic +4. Copy the "App ID" + +### Client Token +1. In the same Basic settings page +2. Copy the "Client Token" +3. If you don't see it, you may need to generate one + +## Testing + +1. Replace all placeholder values with your actual Facebook credentials +2. Run `flutter pub get` to install dependencies +3. Run the app and test the "Continue with Facebook" button +4. Ensure your Facebook app is configured to allow the bundle ID/package name of your Flutter app + +## Troubleshooting + +- **Android**: Make sure your package name in `android/app/build.gradle` matches what's configured in Facebook +- **iOS**: Make sure your bundle identifier matches what's configured in Facebook +- **Both platforms**: Ensure your Facebook app is not in "Development Mode" if testing with non-developer accounts +- Check that your Supabase Facebook OAuth configuration matches your Facebook app settings + +## Security Notes + +- Never commit real Facebook credentials to version control +- Consider using environment variables or secure configuration management +- Regularly rotate your Facebook Client Token \ No newline at end of file diff --git a/packages/supabase_flutter/example/README.md b/packages/supabase_flutter/example/README.md index 1f73590d..9ad118f2 100644 --- a/packages/supabase_flutter/example/README.md +++ b/packages/supabase_flutter/example/README.md @@ -1,7 +1,25 @@ -# Profile Example +# Supabase Flutter Examples -Basic example of how to signup/login using Supabase auth and read and write from your Supabase -database. +Comprehensive examples showcasing the Supabase Flutter library features including authentication, database operations, realtime subscriptions, and file storage. + +## Features Included + +- **Authentication**: Sign up, sign in, social login (Facebook), password reset, and session management +- **Database**: CRUD operations with PostgreSQL database +- **Realtime**: Listen to database changes in real-time using WebSocket connections +- **Storage**: File upload, download, and management functionality + +## Getting Started + +1. Set up your Supabase project at [https://supabase.com](https://supabase.com) +2. Copy your project URL and anon key +3. Set environment variables or update the default values in `lib/main.dart`: + ``` + SUPABASE_URL=your_supabase_url + SUPABASE_ANON_KEY=your_supabase_anon_key + ``` +4. Run the SQL setup below in your Supabase SQL editor +5. For Facebook authentication, follow the Facebook auth setup guide ## SQL diff --git a/packages/supabase_flutter/example/android/app/build.gradle b/packages/supabase_flutter/example/android/app/build.gradle index b5511a9a..fab38065 100644 --- a/packages/supabase_flutter/example/android/app/build.gradle +++ b/packages/supabase_flutter/example/android/app/build.gradle @@ -11,12 +11,12 @@ android { ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 + jvmTarget = JavaVersion.VERSION_11.toString() } defaultConfig { diff --git a/packages/supabase_flutter/example/ios/Runner/Info.plist b/packages/supabase_flutter/example/ios/Runner/Info.plist index 7f553465..55e9c3bc 100644 --- a/packages/supabase_flutter/example/ios/Runner/Info.plist +++ b/packages/supabase_flutter/example/ios/Runner/Info.plist @@ -47,5 +47,41 @@ UIApplicationSupportsIndirectInputEvents + + + CFBundleURLTypes + + + CFBundleURLName + facebook-login + CFBundleURLSchemes + + fbYOUR_FACEBOOK_APP_ID + + + + FacebookAppID + YOUR_FACEBOOK_APP_ID + FacebookClientToken + YOUR_FACEBOOK_CLIENT_TOKEN + FacebookDisplayName + Supabase Flutter Example + LSApplicationQueriesSchemes + + fbapi + fbapi20130214 + fbapi20130410 + fbapi20130702 + fbapi20131010 + fbapi20131024 + fbapi20140410 + fbapi20140116 + fbapi20150313 + fbapi20150629 + fbauth + fbauth2 + fb-messenger-api + fb-messenger-share-api + diff --git a/packages/supabase_flutter/example/lib/main.dart b/packages/supabase_flutter/example/lib/main.dart index eb60a474..30f6a36c 100644 --- a/packages/supabase_flutter/example/lib/main.dart +++ b/packages/supabase_flutter/example/lib/main.dart @@ -1,266 +1,39 @@ import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -Future main() async { - await Supabase.initialize(url: 'SUPABASE_URL', anonKey: 'SUPABASE_ANON_KEY'); - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({Key? key}) : super(key: key); +import 'screens/home_screen.dart'; - @override - Widget build(BuildContext context) { - return const MaterialApp( - title: 'Supabase Flutter Demo', - home: MyWidget(), - ); - } -} +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); -class MyWidget extends StatefulWidget { - const MyWidget({Key? key}) : super(key: key); + await Supabase.initialize( + url: const String.fromEnvironment( + 'SUPABASE_URL', + defaultValue: 'http://127.0.0.1:54321', + ), + anonKey: const String.fromEnvironment( + 'SUPABASE_ANON_KEY', + defaultValue: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0', + ), + ); - @override - State createState() => _MyWidgetState(); + runApp(const SupabaseFlutterExampleApp()); } -class _MyWidgetState extends State { - User? _user; - @override - void initState() { - _getAuth(); - super.initState(); - } - - Future _getAuth() async { - setState(() { - _user = Supabase.instance.client.auth.currentUser; - }); - Supabase.instance.client.auth.onAuthStateChange.listen((data) { - setState(() { - _user = data.session?.user; - }); - }); - } +class SupabaseFlutterExampleApp extends StatelessWidget { + const SupabaseFlutterExampleApp({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Profile Example'), + return MaterialApp( + title: 'Supabase Flutter Examples', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), + useMaterial3: true, ), - body: _user == null ? const _LoginForm() : const _ProfileForm(), + home: const HomeScreen(), ); } } -class _LoginForm extends StatefulWidget { - const _LoginForm({Key? key}) : super(key: key); - - @override - State<_LoginForm> createState() => _LoginFormState(); -} - -class _LoginFormState extends State<_LoginForm> { - bool _loading = false; - final _emailController = TextEditingController(); - final _passwordController = TextEditingController(); - - @override - void dispose() { - _emailController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - children: [ - TextFormField( - keyboardType: TextInputType.emailAddress, - controller: _emailController, - decoration: const InputDecoration(label: Text('Email')), - ), - const SizedBox(height: 16), - TextFormField( - obscureText: true, - controller: _passwordController, - decoration: const InputDecoration(label: Text('Password')), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () async { - setState(() { - _loading = true; - }); - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - final email = _emailController.text; - final password = _passwordController.text; - await Supabase.instance.client.auth.signInWithPassword( - email: email, - password: password, - ); - } catch (e) { - scaffoldMessenger.showSnackBar(const SnackBar( - content: Text('Login failed'), - backgroundColor: Colors.red, - )); - setState(() { - _loading = false; - }); - } - }, - child: const Text('Login'), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () async { - setState(() { - _loading = true; - }); - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - final email = _emailController.text; - final password = _passwordController.text; - await Supabase.instance.client.auth.signUp( - email: email, - password: password, - ); - } catch (e) { - scaffoldMessenger.showSnackBar(const SnackBar( - content: Text('Signup failed'), - backgroundColor: Colors.red, - )); - setState(() { - _loading = false; - }); - } - }, - child: const Text('Signup'), - ), - ], - ); - } -} - -class _ProfileForm extends StatefulWidget { - const _ProfileForm({Key? key}) : super(key: key); - - @override - State<_ProfileForm> createState() => _ProfileFormState(); -} - -class _ProfileFormState extends State<_ProfileForm> { - var _loading = true; - final _usernameController = TextEditingController(); - final _websiteController = TextEditingController(); - - @override - void initState() { - _loadProfile(); - super.initState(); - } - - @override - void dispose() { - _usernameController.dispose(); - _websiteController.dispose(); - super.dispose(); - } - - Future _loadProfile() async { - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - final userId = Supabase.instance.client.auth.currentUser!.id; - final data = (await Supabase.instance.client - .from('profiles') - .select() - .match({'id': userId}).maybeSingle()); - if (data != null) { - setState(() { - _usernameController.text = data['username']; - _websiteController.text = data['website']; - }); - } - } catch (e) { - scaffoldMessenger.showSnackBar(const SnackBar( - content: Text('Error occurred while getting profile'), - backgroundColor: Colors.red, - )); - } - setState(() { - _loading = false; - }); - } - - @override - Widget build(BuildContext context) { - return _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - children: [ - TextFormField( - controller: _usernameController, - decoration: const InputDecoration( - label: Text('Username'), - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _websiteController, - decoration: const InputDecoration( - label: Text('Website'), - ), - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () async { - final ScaffoldMessengerState scaffoldMessenger = - ScaffoldMessenger.of(context); - try { - setState(() { - _loading = true; - }); - final userId = - Supabase.instance.client.auth.currentUser!.id; - final username = _usernameController.text; - final website = _websiteController.text; - await Supabase.instance.client.from('profiles').upsert({ - 'id': userId, - 'username': username, - 'website': website, - }); - if (mounted) { - scaffoldMessenger.showSnackBar(const SnackBar( - content: Text('Saved profile'), - )); - } - } catch (e) { - scaffoldMessenger.showSnackBar(const SnackBar( - content: Text('Error saving profile'), - backgroundColor: Colors.red, - )); - } - setState(() { - _loading = false; - }); - }, - child: const Text('Save')), - const SizedBox(height: 16), - TextButton( - onPressed: () => Supabase.instance.client.auth.signOut(), - child: const Text('Sign Out')), - ], - ); - } -} diff --git a/packages/supabase_flutter/example/lib/screens/auth_screen.dart b/packages/supabase_flutter/example/lib/screens/auth_screen.dart new file mode 100644 index 00000000..74459a10 --- /dev/null +++ b/packages/supabase_flutter/example/lib/screens/auth_screen.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_facebook_auth/flutter_facebook_auth.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class AuthScreen extends StatefulWidget { + const AuthScreen({super.key}); + + @override + State createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _loading = false; + User? _user; + + @override + void initState() { + super.initState(); + _user = Supabase.instance.client.auth.currentUser; + _setupAuthListener(); + } + + void _setupAuthListener() { + Supabase.instance.client.auth.onAuthStateChange.listen((data) { + if (mounted) { + setState(() { + _user = data.session?.user; + }); + } + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _signUp() async { + setState(() => _loading = true); + + try { + final response = await Supabase.instance.client.auth.signUp( + email: _emailController.text, + password: _passwordController.text, + ); + + if (mounted) { + if (response.user != null && response.session == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Check your email for confirmation!'), + backgroundColor: Colors.green, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sign up successful!'), + backgroundColor: Colors.green, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sign up failed: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _signIn() async { + setState(() => _loading = true); + + try { + await Supabase.instance.client.auth.signInWithPassword( + email: _emailController.text, + password: _passwordController.text, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sign in successful!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sign in failed: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _signOut() async { + setState(() => _loading = true); + + try { + await Supabase.instance.client.auth.signOut(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Signed out successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Sign out failed: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _signInWithFacebook() async { + setState(() => _loading = true); + + try { + final LoginResult result = await FacebookAuth.instance.login( + permissions: ['email', 'public_profile'], + ); + + if (result.status == LoginStatus.success) { + final accessToken = result.accessToken!.tokenString; + + await Supabase.instance.client.auth.signInWithIdToken( + provider: OAuthProvider.facebook, + idToken: accessToken, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Facebook sign in successful!'), + backgroundColor: Colors.green, + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Facebook sign in cancelled: ${result.status}'), + backgroundColor: Colors.orange, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Facebook sign in failed: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Authentication Examples'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Authentication Status', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Current User:', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (_user != null) ...[ + Text('Email: ${_user!.email ?? 'N/A'}'), + Text('ID: ${_user!.id}'), + Text('Created: ${_user!.createdAt}'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loading ? null : _signOut, + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign Out'), + ), + ] else ...[ + const Text('Not authenticated'), + ], + ], + ), + ), + ), + const SizedBox(height: 24), + if (_user == null) ...[ + Text( + 'Sign In / Sign Up', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.lock), + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: _loading ? null : _signIn, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Sign In'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: _loading ? null : _signUp, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Sign Up'), + ), + ), + ], + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + Text( + 'Or sign in with:', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _loading ? null : _signInWithFacebook, + icon: const Icon(Icons.facebook, color: Colors.white), + label: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text('Continue with Facebook'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1877F2), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/packages/supabase_flutter/example/lib/screens/database_screen.dart b/packages/supabase_flutter/example/lib/screens/database_screen.dart new file mode 100644 index 00000000..e8ef015a --- /dev/null +++ b/packages/supabase_flutter/example/lib/screens/database_screen.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class DatabaseScreen extends StatefulWidget { + const DatabaseScreen({super.key}); + + @override + State createState() => _DatabaseScreenState(); +} + +class _DatabaseScreenState extends State { + final _titleController = TextEditingController(); + final _descriptionController = TextEditingController(); + List> _todos = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _loadTodos(); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _loadTodos() async { + setState(() => _loading = true); + + try { + final response = await Supabase.instance.client + .from('todos') + .select() + .order('created_at', ascending: false); + + setState(() => _todos = List>.from(response)); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading todos: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _addTodo() async { + if (_titleController.text.isEmpty) return; + + setState(() => _loading = true); + + try { + await Supabase.instance.client.from('todos').insert({ + 'title': _titleController.text, + 'description': _descriptionController.text, + 'is_complete': false, + }); + + _titleController.clear(); + _descriptionController.clear(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Todo added successfully!'), + backgroundColor: Colors.green, + ), + ); + } + + await _loadTodos(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error adding todo: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _updateTodo(int id, bool isComplete) async { + try { + await Supabase.instance.client + .from('todos') + .update({'is_complete': isComplete}) + .eq('id', id); + + await _loadTodos(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error updating todo: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _deleteTodo(int id) async { + try { + await Supabase.instance.client + .from('todos') + .delete() + .eq('id', id); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Todo deleted successfully!'), + backgroundColor: Colors.green, + ), + ); + } + + await _loadTodos(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error deleting todo: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Database Examples'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Todo List (CRUD Operations)', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Todo Title', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.title), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _descriptionController, + decoration: const InputDecoration( + labelText: 'Description (optional)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loading ? null : _addTodo, + icon: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add), + label: const Text('Add Todo'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Todos (${_todos.length})', + style: Theme.of(context).textTheme.titleLarge, + ), + IconButton( + onPressed: _loading ? null : _loadTodos, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _loading && _todos.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _todos.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No todos yet', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Add your first todo above', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), + Text( + 'Note: This example requires a "todos" table in your Supabase database', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.orange[700], + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + itemCount: _todos.length, + itemBuilder: (context, index) { + final todo = _todos[index]; + final isComplete = todo['is_complete'] ?? false; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: Checkbox( + value: isComplete, + onChanged: (value) => _updateTodo( + todo['id'], + value ?? false, + ), + ), + title: Text( + todo['title'] ?? '', + style: TextStyle( + decoration: isComplete + ? TextDecoration.lineThrough + : null, + ), + ), + subtitle: todo['description'] != null && + todo['description'].toString().isNotEmpty + ? Text(todo['description']) + : null, + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _showDeleteConfirmation(todo['id']), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _showDeleteConfirmation(int id) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Todo'), + content: const Text('Are you sure you want to delete this todo?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _deleteTodo(id); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/supabase_flutter/example/lib/screens/home_screen.dart b/packages/supabase_flutter/example/lib/screens/home_screen.dart new file mode 100644 index 00000000..d93f7868 --- /dev/null +++ b/packages/supabase_flutter/example/lib/screens/home_screen.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'auth_screen.dart'; +import 'database_screen.dart'; +import 'storage_screen.dart'; +import 'realtime_screen.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Supabase Flutter Examples'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Welcome to Supabase Flutter Examples', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Explore different Supabase features through interactive examples:', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Expanded( + child: GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + children: [ + _ExampleCard( + title: 'Authentication', + description: 'Sign up, sign in, and manage user sessions', + icon: Icons.person, + color: Colors.blue, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const AuthScreen()), + ), + ), + _ExampleCard( + title: 'Database', + description: 'CRUD operations with PostgreSQL', + icon: Icons.storage, + color: Colors.green, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const DatabaseScreen()), + ), + ), + _ExampleCard( + title: 'Storage', + description: 'Upload and manage files', + icon: Icons.cloud_upload, + color: Colors.orange, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const StorageScreen()), + ), + ), + _ExampleCard( + title: 'Realtime', + description: 'Listen to database changes in real-time', + icon: Icons.sensors, + color: Colors.purple, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const RealtimeScreen()), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.title, + required this.description, + required this.icon, + required this.color, + required this.onTap, + }); + + final String title; + final String description; + final IconData icon; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 48, + color: color, + ), + const SizedBox(height: 12), + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + description, + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/packages/supabase_flutter/example/lib/screens/realtime_screen.dart b/packages/supabase_flutter/example/lib/screens/realtime_screen.dart new file mode 100644 index 00000000..ee8ab81a --- /dev/null +++ b/packages/supabase_flutter/example/lib/screens/realtime_screen.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class RealtimeScreen extends StatefulWidget { + const RealtimeScreen({super.key}); + + @override + State createState() => _RealtimeScreenState(); +} + +class _RealtimeScreenState extends State { + final _messageController = TextEditingController(); + final _channelNameController = TextEditingController(); + List> _messages = []; + RealtimeChannel? _channel; + String _channelName = 'public:messages'; + bool _isConnected = false; + List _onlineUsers = []; + + @override + void initState() { + super.initState(); + _channelNameController.text = _channelName; + _subscribeToChannel(); + } + + @override + void dispose() { + _messageController.dispose(); + _channelNameController.dispose(); + _channel?.unsubscribe(); + super.dispose(); + } + + void _subscribeToChannel() { + _channel?.unsubscribe(); + + _channel = Supabase.instance.client.channel(_channelName); + + // Listen to database changes + _channel! + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'messages', + callback: (payload) { + if (mounted) { + setState(() { + if (payload.eventType == PostgresChangeEvent.insert) { + _messages.insert(0, payload.newRecord); + } else if (payload.eventType == PostgresChangeEvent.update) { + final index = _messages.indexWhere( + (message) => message['id'] == payload.newRecord['id'], + ); + if (index >= 0) { + _messages[index] = payload.newRecord; + } + } else if (payload.eventType == PostgresChangeEvent.delete) { + _messages.removeWhere( + (message) => message['id'] == payload.oldRecord['id'], + ); + } + }); + } + }, + ) + .onBroadcast( + event: 'message', + callback: (payload) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Broadcast: ${payload['message']}'), + backgroundColor: Colors.blue, + duration: const Duration(seconds: 2), + ), + ); + } + }, + ) + .onPresenceSync((payload) { + if (mounted) { + // Simple presence tracking - just show connected status + setState(() { + _onlineUsers = ['Connected Users']; // Placeholder for presence demo + }); + } + }) + .subscribe((status, [error]) { + if (mounted) { + setState(() { + _isConnected = status == RealtimeSubscribeStatus.subscribed; + }); + + if (status == RealtimeSubscribeStatus.subscribed) { + // Track presence + _channel!.track({ + 'user': 'User${DateTime.now().millisecondsSinceEpoch}', + 'online_at': DateTime.now().toIso8601String(), + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Connected to realtime!'), + backgroundColor: Colors.green, + ), + ); + } else if (status == RealtimeSubscribeStatus.channelError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Connection error: ${error?.toString() ?? 'Unknown error'}'), + backgroundColor: Colors.red, + ), + ); + } + } + }); + } + + Future _sendMessage() async { + if (_messageController.text.isEmpty) return; + + try { + // Insert into database (will trigger realtime update) + await Supabase.instance.client.from('messages').insert({ + 'content': _messageController.text, + 'user_id': Supabase.instance.client.auth.currentUser?.id ?? 'anonymous', + 'created_at': DateTime.now().toIso8601String(), + }); + + _messageController.clear(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error sending message: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _sendBroadcast() async { + if (_messageController.text.isEmpty) return; + + try { + await _channel?.sendBroadcastMessage( + event: 'message', + payload: {'message': _messageController.text}, + ); + + _messageController.clear(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error sending broadcast: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + void _changeChannel() { + final newChannel = _channelNameController.text; + if (newChannel.isEmpty || newChannel == _channelName) return; + + setState(() { + _channelName = newChannel; + _messages.clear(); + _onlineUsers.clear(); + }); + + _subscribeToChannel(); + } + + Future _loadMessages() async { + try { + final response = await Supabase.instance.client + .from('messages') + .select() + .order('created_at', ascending: false) + .limit(50); + + setState(() { + _messages = List>.from(response); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading messages: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Realtime Examples'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + actions: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: _isConnected ? Colors.green : Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _isConnected ? 'Connected' : 'Disconnected', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Channel Settings', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _channelNameController, + decoration: const InputDecoration( + labelText: 'Channel Name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.tag), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _changeChannel, + child: const Text('Switch'), + ), + ], + ), + const SizedBox(height: 8), + if (_onlineUsers.isNotEmpty) ...[ + Text( + 'Online Users (${_onlineUsers.length}):', + style: Theme.of(context).textTheme.bodySmall, + ), + Wrap( + children: _onlineUsers.map((user) { + return Chip( + label: Text(user, style: const TextStyle(fontSize: 12)), + backgroundColor: Colors.green[100], + ); + }).toList(), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextFormField( + controller: _messageController, + decoration: const InputDecoration( + labelText: 'Type a message', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.message), + ), + onFieldSubmitted: (_) => _sendMessage(), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isConnected ? _sendMessage : null, + icon: const Icon(Icons.send), + label: const Text('Send to DB'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: _isConnected ? _sendBroadcast : null, + icon: const Icon(Icons.radio), + label: const Text('Broadcast'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Messages (${_messages.length})', + style: Theme.of(context).textTheme.titleLarge, + ), + IconButton( + onPressed: _loadMessages, + icon: const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _messages.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Send a message to get started', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), + Text( + 'Note: This example requires a "messages" table in your Supabase database', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.orange[700], + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : ListView.builder( + reverse: true, + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + final content = message['content'] ?? ''; + final userId = message['user_id'] ?? 'Unknown'; + final createdAt = message['created_at'] ?? ''; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(content), + subtitle: Text('From: $userId'), + trailing: Text( + _formatTimestamp(createdAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + String _formatTimestamp(String timestamp) { + try { + final dateTime = DateTime.parse(timestamp); + final now = DateTime.now(); + final diff = now.difference(dateTime); + + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } catch (e) { + return timestamp; + } + } +} \ No newline at end of file diff --git a/packages/supabase_flutter/example/lib/screens/storage_screen.dart b/packages/supabase_flutter/example/lib/screens/storage_screen.dart new file mode 100644 index 00000000..e6d48dfd --- /dev/null +++ b/packages/supabase_flutter/example/lib/screens/storage_screen.dart @@ -0,0 +1,466 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class StorageScreen extends StatefulWidget { + const StorageScreen({super.key}); + + @override + State createState() => _StorageScreenState(); +} + +class _StorageScreenState extends State { + final _bucketNameController = TextEditingController(); + List _files = []; + List _buckets = []; + String? _selectedBucket; + bool _loading = false; + + @override + void initState() { + super.initState(); + _loadBuckets(); + } + + @override + void dispose() { + _bucketNameController.dispose(); + super.dispose(); + } + + Future _loadBuckets() async { + setState(() => _loading = true); + + try { + final buckets = await Supabase.instance.client.storage.listBuckets(); + setState(() { + _buckets = buckets; + if (_buckets.isNotEmpty && _selectedBucket == null) { + _selectedBucket = _buckets.first.name; + _loadFiles(); + } + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading buckets: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _loadFiles() async { + if (_selectedBucket == null) return; + + setState(() => _loading = true); + + try { + final files = await Supabase.instance.client.storage + .from(_selectedBucket!) + .list(); + setState(() => _files = files); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error loading files: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _createBucket() async { + if (_bucketNameController.text.isEmpty) return; + + setState(() => _loading = true); + + try { + await Supabase.instance.client.storage + .createBucket(_bucketNameController.text); + + _bucketNameController.clear(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Bucket created successfully!'), + backgroundColor: Colors.green, + ), + ); + } + + await _loadBuckets(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error creating bucket: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _uploadDummyFile() async { + if (_selectedBucket == null) return; + + setState(() => _loading = true); + + try { + final fileName = 'dummy_${DateTime.now().millisecondsSinceEpoch}.txt'; + final fileContent = 'This is a dummy file created at ${DateTime.now()}'; + + await Supabase.instance.client.storage + .from(_selectedBucket!) + .uploadBinary( + fileName, + Uint8List.fromList(fileContent.codeUnits), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('File uploaded successfully!'), + backgroundColor: Colors.green, + ), + ); + } + + await _loadFiles(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error uploading file: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + setState(() => _loading = false); + } + } + + Future _deleteFile(String fileName) async { + if (_selectedBucket == null) return; + + try { + await Supabase.instance.client.storage + .from(_selectedBucket!) + .remove([fileName]); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('File deleted successfully!'), + backgroundColor: Colors.green, + ), + ); + } + + await _loadFiles(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error deleting file: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _downloadFile(String fileName) async { + if (_selectedBucket == null) return; + + try { + final response = await Supabase.instance.client.storage + .from(_selectedBucket!) + .download(fileName); + + final content = String.fromCharCodes(response); + + if (mounted) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('File: $fileName'), + content: SingleChildScrollView( + child: Text(content), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error downloading file: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Storage Examples'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Storage Management', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Create Bucket', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _bucketNameController, + decoration: const InputDecoration( + labelText: 'Bucket Name', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.folder), + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _loading ? null : _createBucket, + child: _loading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create'), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Select Bucket', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + IconButton( + onPressed: _loading ? null : _loadBuckets, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], + ), + if (_buckets.isEmpty) ...[ + const Text('No buckets found. Create one above.'), + ] else ...[ + DropdownButtonFormField( + initialValue: _selectedBucket, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + items: _buckets.map((bucket) { + return DropdownMenuItem( + value: bucket.name, + child: Text(bucket.name), + ); + }).toList(), + onChanged: (value) { + setState(() => _selectedBucket = value); + _loadFiles(); + }, + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _selectedBucket == null || _loading ? null : _uploadDummyFile, + icon: const Icon(Icons.upload_file), + label: const Text('Upload Dummy File'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 16), + if (_selectedBucket != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Files in $_selectedBucket (${_files.length})', + style: Theme.of(context).textTheme.titleLarge, + ), + IconButton( + onPressed: _loading ? null : _loadFiles, + icon: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: _loading && _files.isEmpty + ? const Center(child: CircularProgressIndicator()) + : _files.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'No files in this bucket', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Upload a dummy file to get started', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey[600], + ), + ), + ], + ), + ) + : ListView.builder( + itemCount: _files.length, + itemBuilder: (context, index) { + final file = _files[index]; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.insert_drive_file), + title: Text(file.name), + subtitle: Text( + 'Size: ${_formatFileSize(file.metadata?['size'])} • ' + 'Modified: ${file.updatedAt ?? 'Unknown'}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.download, color: Colors.blue), + onPressed: () => _downloadFile(file.name), + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _showDeleteFileConfirmation(file.name), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ], + ), + ), + ); + } + + String _formatFileSize(dynamic size) { + if (size == null) return 'Unknown'; + + final bytes = size is int ? size : int.tryParse(size.toString()) ?? 0; + if (bytes < 1024) return '${bytes}B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB'; + } + + void _showDeleteFileConfirmation(String fileName) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete File'), + content: Text('Are you sure you want to delete "$fileName"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context); + _deleteFile(fileName); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc index 3792af4b..5a27a5d6 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) gtk_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); gtk_plugin_register_with_registrar(gtk_registrar); diff --git a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake index 5d074230..98d181be 100644 --- a/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux gtk url_launcher_linux ) diff --git a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift index 92b64978..1ff10844 100644 --- a/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/supabase_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,16 @@ import FlutterMacOS import Foundation import app_links +import facebook_auth_desktop +import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FacebookAuthDesktopPlugin.register(with: registry.registrar(forPlugin: "FacebookAuthDesktopPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/packages/supabase_flutter/example/pubspec.yaml b/packages/supabase_flutter/example/pubspec.yaml index 33291ab3..b9c94996 100644 --- a/packages/supabase_flutter/example/pubspec.yaml +++ b/packages/supabase_flutter/example/pubspec.yaml @@ -1,17 +1,19 @@ name: supabase_flutter_example -description: Demonstrates how to use supabase_flutter +description: "Examples app showcasing usage of Supabase Flutter library" publish_to: 'none' version: 1.0.0+1 environment: - sdk: '>=2.15.0 <3.0.0' + sdk: ^3.7.2 dependencies: flutter: sdk: flutter supabase_flutter: ^2.9.1 + cupertino_icons: ^1.0.8 + flutter_facebook_auth: ^7.1.1 dev_dependencies: flutter_test: diff --git a/packages/supabase_flutter/example/supabase/.gitignore b/packages/supabase_flutter/example/supabase/.gitignore new file mode 100644 index 00000000..ad9264f0 --- /dev/null +++ b/packages/supabase_flutter/example/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/packages/supabase_flutter/example/supabase/config.toml b/packages/supabase_flutter/example/supabase/config.toml new file mode 100644 index 00000000..7a454720 --- /dev/null +++ b/packages/supabase_flutter/example/supabase/config.toml @@ -0,0 +1,334 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "examples" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 1 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc index 785a046f..563050d0 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake index 8f8ee4f2..a3836b2d 100644 --- a/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/supabase_flutter/example/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + flutter_secure_storage_windows url_launcher_windows )