diff --git a/README.md b/README.md index 6772c15..b86ee26 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,80 @@ SupaSocialsAuth( ), ``` +## SupaAvatar + +A plug-and-play widget to show and edit a Supabase user's profile image. +Supports both readonly and editable modes with full customization options. + + +avatar-screenshot + + +### Usage + +```dart +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: SupaAvatar( + radius: 50, + isEditable: true, + ), + ); + } +} +``` +When you tap on the avatar & `isEditable: true`, the avatar editor is opened: + +| dialog | modal | +| -------- | ------- | +| dialog-editor | modal-editor | +| `editorType: SupaAvatarEditorType.dialog,` | `editorType: SupaAvatarEditorType.modal,` | + +### Customization + +```dart +SupaAvatar( + radius: 60, + isEditable: true, + supabaseStorageBucket: 'avatars', + supabaseStoragePath: 'profile_image', // stored as userId/profile_image + supabaseUserAttributeImageUrlKey: 'avatar_url', + + fallbackIcon: Icon(Icons.person_2_rounded, size: 32), + cacheBuster: DateTime.now().millisecondsSinceEpoch.toString(), + + // editor customization + editorType: SupaAvatarEditorType.dialog, // defaults to SupaAvatarEditorType.modal + editorShape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + editorBackgroundColor: Colors.grey[900], + + // Snackbar colors + snackBarBackgroundColor: Colors.green[600], + snackBarTextColor: Colors.white, + snackBarErrorBackgroundColor: Colors.red[600], + snackBarErrorTextColor: Colors.white, + snackBarDuration: Duration(seconds: 2), +) +``` + + +### Notes + +- The image is stored in `avatars/{userId}/{supabaseStoragePath}`. +- The metadata field (`avatar_url` by default) is updated on upload/remove. +- Uses `cacheBuster` to bypass CDN cache after upload. +- Tapping on avatar opens a bottom sheet or dialog for edit options when `isEditable` is true. + +--- + ## Theming This library uses bare Flutter components so that you can control the appearance of the components using your own theme. diff --git a/example/.gitignore b/example/.gitignore index 1f061d7..9f86c2c 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -6,9 +6,11 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ migrate_working_dir/ # IntelliJ related diff --git a/example/assets/supabase-logo-icon.png b/example/assets/supabase-logo-icon.png new file mode 100644 index 0000000..a3b7b36 Binary files /dev/null and b/example/assets/supabase-logo-icon.png differ diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 9625e10..7c56964 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 11.0 + 12.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index 88359b2..279576f 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '11.0' +# platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index c7b21ec..b8f16e2 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,42 +1,93 @@ PODS: - - app_links (0.0.1): + - app_links (0.0.2): - Flutter + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core - Flutter (1.0.0) + - google_sign_in_ios (0.0.1): + - AppAuth (>= 1.7.4) + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 7.1) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (7.1.0): + - AppAuth (< 2.0, >= 1.7.3) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core + - image_picker_ios (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sign_in_with_apple (0.0.1): + - Flutter - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - Flutter (from `Flutter`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - AppAuth + - GoogleSignIn + - GTMAppAuth + - GTMSessionFetcher + EXTERNAL SOURCES: app_links: :path: ".symlinks/plugins/app_links/ios" Flutter: :path: Flutter + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sign_in_with_apple: + :path: ".symlinks/plugins/sign_in_with_apple/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: ab4ba54d10a13d45825336bc9707b5eadee81191 - Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8 - shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + google_sign_in_ios: 19297361f2c51d7d8ac0201b866ef1fa5d1f94a8 + GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 +PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 578e7a0..3bd3034 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 5577070999A7873150D42AF2 /* [CP] Embed Pods Frameworks */, + 7507BAEEC32E71631CAEFDBF /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -156,7 +157,7 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -231,6 +232,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 7507BAEEC32E71631CAEFDBF /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -343,7 +361,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -420,7 +438,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -469,7 +487,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a6b826d..c53e2b3 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4..b636303 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import UIKit import Flutter -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, diff --git a/example/lib/main.dart b/example/lib/main.dart index ad28ebd..2cc8343 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,21 +1,14 @@ -import 'package:example/phone_sign_up.dart'; +import 'package:example/src/core/app_router.dart'; import 'package:flutter/material.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import './home.dart'; -import './sign_in.dart'; -import './magic_link.dart'; -import './update_password.dart'; -import 'phone_sign_in.dart'; -import './verify_phone.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - - /// TODO: replace with your credentials await Supabase.initialize( - url: 'https://yoursupabaseurl.supabase.co', - anonKey: 'your_anon_key', + url: 'https://hvjjvggueouofkqafawj.supabase.co', + anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imh2amp2Z2d1ZW91b2ZrcWFmYXdqIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDM5ODA2MTMsImV4cCI6MjA1OTU1NjYxM30.m0VSa8jdoNcJFmS18xMwtM0P9yblakd2QgEXkW5kAqU', ); runApp(const MyApp()); } @@ -26,40 +19,16 @@ class MyApp extends StatelessWidget { // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( + return MaterialApp.router( debugShowCheckedModeBanner: false, - title: 'Flutter Demo', + title: 'Supabase Auth UI Demo', theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), ), ), - initialRoute: '/', - routes: { - '/': (context) => const SignUp(), - '/magic_link': (context) => const MagicLink(), - '/update_password': (context) => const UpdatePassword(), - '/phone_sign_in': (context) => const PhoneSignIn(), - '/phone_sign_up': (context) => const PhoneSignUp(), - '/verify_phone': (context) => const VerifyPhone(), - '/home': (context) => const Home(), - }, - onUnknownRoute: (RouteSettings settings) { - return MaterialPageRoute( - settings: settings, - builder: (BuildContext context) => const Scaffold( - body: Center( - child: Text( - 'Not Found', - style: TextStyle( - fontSize: 42, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ); - }, + routerConfig: appRouter, ); } } diff --git a/example/lib/src/core/app_router.dart b/example/lib/src/core/app_router.dart new file mode 100644 index 0000000..7b56c98 --- /dev/null +++ b/example/lib/src/core/app_router.dart @@ -0,0 +1,121 @@ +// initialRoute: '/', +// routes: { +// '/': (context) => const SignUp(), +// '/magic_link': (context) => const MagicLink(), +// '/update_password': (context) => const UpdatePassword(), +// '/phone_sign_in': (context) => const PhoneSignIn(), +// '/phone_sign_up': (context) => const PhoneSignUp(), +// '/verify_phone': (context) => const VerifyPhone(), +// '/home': (context) => const Home(), +// }, + +import 'dart:async'; + +import 'package:example/src/core/constants.dart'; +import 'package:example/src/views/screens/home.dart'; +import 'package:example/src/views/screens/magic_link.dart'; +import 'package:example/src/views/screens/phone_sign_in.dart'; +import 'package:example/src/views/screens/sign_in_up.dart'; +import 'package:example/src/views/screens/splash.dart'; +import 'package:example/src/views/screens/update_password.dart'; +import 'package:example/src/views/screens/verify_phone.dart'; +import 'package:flutter/foundation.dart'; +import 'package:go_router/go_router.dart'; + +// define the app routes enum with the path for each screen +enum AppRoute { + signInUp('/sign_in_up'), + magicLink('/magic_link'), + updatePassword('/update_password'), + phoneSignIn('/phone_sign_in'), + phoneSignUp('/phone_sign_up'), + verifyPhone('/verify_phone'), + home('/'), + splash('/splash'); + + const AppRoute(this.path); + final String path; +} + +// define the app router using GoRouter +final appRouter = GoRouter( + initialLocation: AppRoute.splash.path, + debugLogDiagnostics: true, + redirect: (context, state) { + // Check if the user is authenticated + final user = supa.auth.currentUser; + final isAuthenticated = user != null; + + // If the user is authenticated and trying to access the sign-in page, redirect to home + if (isAuthenticated && (state.matchedLocation == AppRoute.signInUp.path || state.matchedLocation == AppRoute.splash.path)) { + return AppRoute.home.path; + } + + // If the user is not authenticated and trying to access the home page, redirect to sign-in + if (!isAuthenticated && (state.matchedLocation == AppRoute.home.path || state.matchedLocation == AppRoute.splash.path)) { + return AppRoute.signInUp.path; + } + + return null; + }, + refreshListenable: GoRouterRefreshStream(supa.auth.onAuthStateChange), + routes: [ + // define the splash screen route + GoRoute( + path: AppRoute.splash.path, + builder: (context, state) => const Splash(), + ), + + GoRoute( + name: AppRoute.signInUp.name, + path: AppRoute.signInUp.path, + builder: (context, state) => const SignInUp(), + ), + GoRoute( + name: AppRoute.magicLink.name, + path: AppRoute.magicLink.path, + builder: (context, state) => const MagicLink(), + ), + GoRoute( + name: AppRoute.updatePassword.name, + path: AppRoute.updatePassword.path, + builder: (context, state) => const UpdatePassword(), + ), + GoRoute( + name: AppRoute.phoneSignIn.name, + path: AppRoute.phoneSignIn.path, + builder: (context, state) => const PhoneSignIn(), + ), + GoRoute( + name: AppRoute.phoneSignUp.name, + path: AppRoute.phoneSignUp.path, + builder: (context, state) => const PhoneSignIn(), + ), + GoRoute( + name: AppRoute.verifyPhone.name, + path: AppRoute.verifyPhone.path, + builder: (context, state) => const VerifyPhone(), + ), + GoRoute( + name: AppRoute.home.name, + path: AppRoute.home.path, + builder: (context, state) => const Home(), + ), + ], +); + +class GoRouterRefreshStream with ChangeNotifier { + GoRouterRefreshStream(Stream stream) { + _subscription = stream.listen((_) { + notifyListeners(); + }); + } + + late final StreamSubscription _subscription; + + @override + void dispose() { + _subscription.cancel(); + super.dispose(); + } +} diff --git a/example/lib/constants.dart b/example/lib/src/core/constants.dart similarity index 77% rename from example/lib/constants.dart rename to example/lib/src/core/constants.dart index 58060b7..64a1ede 100644 --- a/example/lib/constants.dart +++ b/example/lib/src/core/constants.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; AppBar appBar(String title) => AppBar( title: Text(title), @@ -17,3 +18,5 @@ const optionText = Text( const spacer = SizedBox( height: 12, ); + +final supa = Supabase.instance.client; diff --git a/example/lib/home.dart b/example/lib/src/views/screens/home.dart similarity index 78% rename from example/lib/home.dart rename to example/lib/src/views/screens/home.dart index 0b5353d..e449e9e 100644 --- a/example/lib/home.dart +++ b/example/lib/src/views/screens/home.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; class Home extends StatelessWidget { const Home({Key? key}) : super(key: key); @@ -18,10 +18,14 @@ class Home extends StatelessWidget { 'You are home', style: TextStyle(fontSize: 42), ), + const SizedBox(height: 20), + const SupaAvatar( + isEditable: true, + // editorType: SupaAvatarEditorType.dialog, // defaults to modal sheet + ), ElevatedButton( onPressed: () { Supabase.instance.client.auth.signOut(); - Navigator.of(context).pushReplacementNamed('/'); }, child: const Text( 'Log Out', diff --git a/example/lib/magic_link.dart b/example/lib/src/views/screens/magic_link.dart similarity index 76% rename from example/lib/magic_link.dart rename to example/lib/src/views/screens/magic_link.dart index aebea2d..2b61d62 100644 --- a/example/lib/magic_link.dart +++ b/example/lib/src/views/screens/magic_link.dart @@ -1,8 +1,12 @@ +import 'dart:developer'; + +import 'package:example/src/core/app_router.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; class MagicLink extends StatelessWidget { const MagicLink({Key? key}) : super(key: key); @@ -17,7 +21,8 @@ class MagicLink extends StatelessWidget { children: [ SupaMagicAuth( onSuccess: (response) { - Navigator.of(context).pushReplacementNamed('/home'); + log('Magic Link Sign In Success: ${response.toJson()}'); + }, redirectUrl: kIsWeb ? null @@ -29,7 +34,7 @@ class MagicLink extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: () { - Navigator.pushNamed(context, '/sign_in'); + context.goNamed(AppRoute.signInUp.name); }, ), ], diff --git a/example/lib/phone_sign_in.dart b/example/lib/src/views/screens/phone_sign_in.dart similarity index 71% rename from example/lib/phone_sign_in.dart rename to example/lib/src/views/screens/phone_sign_in.dart index 523f763..950faff 100644 --- a/example/lib/phone_sign_in.dart +++ b/example/lib/src/views/screens/phone_sign_in.dart @@ -1,7 +1,12 @@ +import 'dart:developer'; + +import 'package:example/src/core/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; + +import '../../core/constants.dart'; class PhoneSignIn extends StatelessWidget { const PhoneSignIn({Key? key}) : super(key: key); @@ -16,8 +21,8 @@ class PhoneSignIn extends StatelessWidget { children: [ SupaPhoneAuth( authAction: SupaAuthAction.signIn, - onSuccess: (response) { - Navigator.of(context).pushReplacementNamed('/home'); + onSuccess: (AuthResponse response) { + log('Phone Sign In Success: $response'); }, ), TextButton( @@ -26,7 +31,7 @@ class PhoneSignIn extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: () { - Navigator.pushNamed(context, '/'); + context.goNamed(AppRoute.signInUp.name); }, ), ], diff --git a/example/lib/phone_sign_up.dart b/example/lib/src/views/screens/phone_sign_up.dart similarity index 77% rename from example/lib/phone_sign_up.dart rename to example/lib/src/views/screens/phone_sign_up.dart index a508051..33e7009 100644 --- a/example/lib/phone_sign_up.dart +++ b/example/lib/src/views/screens/phone_sign_up.dart @@ -1,7 +1,9 @@ +import 'package:example/src/core/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; class PhoneSignUp extends StatelessWidget { const PhoneSignUp({Key? key}) : super(key: key); @@ -17,7 +19,7 @@ class PhoneSignUp extends StatelessWidget { SupaPhoneAuth( authAction: SupaAuthAction.signUp, onSuccess: (response) { - Navigator.of(context).pushReplacementNamed('/verify_phone'); + context.goNamed(AppRoute.verifyPhone.name); }, ), TextButton( @@ -26,7 +28,7 @@ class PhoneSignUp extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: () { - Navigator.of(context).pushNamed('/sign_in'); + context.goNamed(AppRoute.signInUp.name); }, ), ], diff --git a/example/lib/sign_in.dart b/example/lib/src/views/screens/sign_in_up.dart similarity index 94% rename from example/lib/sign_in.dart rename to example/lib/src/views/screens/sign_in_up.dart index 6492132..37ebaea 100644 --- a/example/lib/sign_in.dart +++ b/example/lib/src/views/screens/sign_in_up.dart @@ -1,16 +1,20 @@ +import 'dart:developer'; + +import 'package:example/src/core/app_router.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; -class SignUp extends StatelessWidget { - const SignUp({Key? key}) : super(key: key); +class SignInUp extends StatelessWidget { + const SignInUp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { void navigateHome(AuthResponse response) { - Navigator.of(context).pushReplacementNamed('/home'); + log('Sign in/up success: ${response.user?.email}'); } final darkModeThemeData = ThemeData.dark().copyWith( @@ -162,14 +166,14 @@ class SignUp extends StatelessWidget { ElevatedButton.icon( icon: const Icon(Icons.email), onPressed: () { - Navigator.popAndPushNamed(context, '/magic_link'); + context.goNamed(AppRoute.magicLink.name); }, label: const Text('Sign in with Magic Link'), ), spacer, ElevatedButton.icon( onPressed: () { - Navigator.popAndPushNamed(context, '/phone_sign_in'); + context.goNamed(AppRoute.phoneSignIn.name); }, icon: const Icon(Icons.phone), label: const Text('Sign in with Phone'), @@ -184,7 +188,7 @@ class SignUp extends StatelessWidget { enableNativeAppleAuth: false, socialProviders: OAuthProvider.values, onSuccess: (session) { - Navigator.of(context).pushReplacementNamed('/home'); + }, ), ], diff --git a/example/lib/src/views/screens/splash.dart b/example/lib/src/views/screens/splash.dart new file mode 100644 index 0000000..0e98f9a --- /dev/null +++ b/example/lib/src/views/screens/splash.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class Splash extends StatelessWidget { + const Splash({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/supabase-logo-icon.png', + height: 100, + ), + const SizedBox(height: 20), + const Text( + 'Welcome to Supabase Auth UI', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 42), + ), + const SizedBox(height: 20), + const CircularProgressIndicator(), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/example/lib/update_password.dart b/example/lib/src/views/screens/update_password.dart similarity index 78% rename from example/lib/update_password.dart rename to example/lib/src/views/screens/update_password.dart index c8c06e7..8713d47 100644 --- a/example/lib/update_password.dart +++ b/example/lib/src/views/screens/update_password.dart @@ -1,7 +1,11 @@ +import 'dart:developer'; + +import 'package:example/src/core/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; class UpdatePassword extends StatelessWidget { const UpdatePassword({Key? key}) : super(key: key); @@ -18,7 +22,8 @@ class UpdatePassword extends StatelessWidget { accessToken: Supabase.instance.client.auth.currentSession!.accessToken, onSuccess: (response) { - Navigator.of(context).pushReplacementNamed('/home'); + log('Update Password Success: $response'); + context.goNamed(AppRoute.home.name); }, ), TextButton( diff --git a/example/lib/verify_phone.dart b/example/lib/src/views/screens/verify_phone.dart similarity index 79% rename from example/lib/verify_phone.dart rename to example/lib/src/views/screens/verify_phone.dart index 59fcd37..3fac3c0 100644 --- a/example/lib/verify_phone.dart +++ b/example/lib/src/views/screens/verify_phone.dart @@ -1,7 +1,11 @@ +import 'dart:developer'; + +import 'package:example/src/core/app_router.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:supabase_auth_ui/supabase_auth_ui.dart'; -import 'constants.dart'; +import '../../core/constants.dart'; class VerifyPhone extends StatelessWidget { const VerifyPhone({Key? key}) : super(key: key); @@ -16,7 +20,8 @@ class VerifyPhone extends StatelessWidget { children: [ SupaVerifyPhone( onSuccess: (response) { - Navigator.of(context).pushReplacementNamed('/home'); + log("Verify Phone Success: $response"); + }, ), TextButton( @@ -25,7 +30,7 @@ class VerifyPhone extends StatelessWidget { style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: () { - Navigator.pushNamed(context, '/forgot_password'); + context.goNamed(AppRoute.updatePassword.name); }, ), TextButton( diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 464810a..e7591d8 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import app_links +import file_selector_macos import google_sign_in_ios import path_provider_foundation import shared_preferences_foundation @@ -14,6 +15,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 4e2f3c0..8b5a915 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,6 +11,7 @@ environment: dependencies: flutter: sdk: flutter + go_router: ^14.8.1 supabase_auth_ui: path: ../ @@ -22,3 +23,6 @@ dev_dependencies: flutter: uses-material-design: true + + assets: + - assets/ diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 785a046..5bbd4c3 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/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")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 8f8ee4f..79001bc 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + file_selector_windows url_launcher_windows ) diff --git a/lib/src/components/supa_avatar/supa_avatar.dart b/lib/src/components/supa_avatar/supa_avatar.dart new file mode 100644 index 0000000..848cce1 --- /dev/null +++ b/lib/src/components/supa_avatar/supa_avatar.dart @@ -0,0 +1,339 @@ +// 📁 supa_avatar.dart +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:mime/mime.dart'; +import 'package:supabase_auth_ui/src/components/supa_avatar/widgets/supa_avatar_editor.dart'; +import 'package:supabase_auth_ui/src/components/supa_avatar/widgets/supa_user_avatar.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; + +enum SupaAvatarEditorType { + modal, + dialog, +} + +class SupaAvatar extends StatefulWidget { + /// Creates a SupaAvatar widget. + /// + /// Displays the current user's avatar from Supabase user metadata or storage, + /// with optional editing functionality (upload, remove) via a modal bottom sheet. + /// + /// Set [isEditable] to true to allow avatar updates via gallery/camera. + const SupaAvatar({ + super.key, + + /// Radius of the avatar circle. + /// Defaults to `40`. + this.radius = 40, + + /// Enables edit mode. If `true`, shows a modal to upload or remove avatar. + this.isEditable = false, + + /// Optional cache buster string added to the image URL to bypass cached versions. + this.cacheBuster, + + /// Custom shape for the editor (used in editable mode). + this.editorShape, + + /// Custom background color for the editor. + this.editorBackgroundColor, + + /// The Supabase storage bucket where avatars are stored. + /// Defaults to `'avatars'`. + this.supabaseStorageBucket = 'avatars', + + /// The path under the user’s folder in the storage bucket where the avatar is saved. + /// Example: if `user.id` is `abc` and [supabaseStoragePath] is `profile`, the full path becomes `abc/profile`. + this.supabaseStoragePath = 'profile', + + /// Widget to show if no avatar is available. + /// Defaults to [Icon(Icons.person)]. + this.fallbackIcon = const Icon(Icons.person), + + /// Background color of the snackbar shown on successful avatar upload or removal. + this.snackBarBackgroundColor, + + /// Text color of the snackbar on success. + this.snackBarTextColor, + + /// Background color of the snackbar shown on error. + this.snackBarErrorBackgroundColor, + + /// Text color of the snackbar on error. + this.snackBarErrorTextColor, + + /// Duration the snackbar is shown. + this.snackBarDuration, + + /// The user metadata key used to retrieve the avatar URL. + /// Defaults to `'avatar_url'`. + this.supabaseUserAttributeImageUrlKey = 'avatar_url', + + /// The type of editor to use for the avatar + /// + /// Defaults to [SupaAvatarEditorType.modal] + /// + /// If you want to use a dialog instead of a modal, set this to [SupaAvatarEditorType.dialog] + this.editorType = SupaAvatarEditorType.modal, + }); + + /// Radius of the avatar circle. + /// Defaults to `40`. + final double radius; + + /// Enables edit mode. If `true`, shows a modal to upload or remove avatar. + final bool isEditable; + + /// Optional cache buster string added to the image URL to bypass cached versions. + final String? cacheBuster; + + /// Custom shape for the modal bottom sheet (used in editable mode). + final ShapeBorder? editorShape; + + /// Custom background color for the modal bottom sheet. + final Color? editorBackgroundColor; + + /// The Supabase storage bucket where avatars are stored. + /// Defaults to `'avatars'`. + final String supabaseStorageBucket; + + /// The path under the user’s folder in the storage bucket where the avatar is saved. + /// Example: if `user.id` is `abc` and [supabaseStoragePath] is `profile`, + /// the full path becomes `abc/profile`. + final String supabaseStoragePath; + + /// Widget to show if no avatar is available. + /// Defaults to [Icon(Icons.person)]. + final Widget fallbackIcon; + + /// Background color of the snackbar shown on successful avatar upload or removal. + final Color? snackBarBackgroundColor; + + /// Text color of the snackbar on success. + final Color? snackBarTextColor; + + /// Background color of the snackbar shown on error. + final Color? snackBarErrorBackgroundColor; + + /// Text color of the snackbar on error. + final Color? snackBarErrorTextColor; + + /// Duration the snackbar is shown. + final Duration? snackBarDuration; + + /// The user metadata key used to retrieve the avatar URL. + /// Defaults to `'avatar_url'`. + final String supabaseUserAttributeImageUrlKey; + + /// The type of editor to use for the avatar + /// + /// Defaults to [SupaAvatarEditorType.modal] + /// + /// If you want to use a dialog instead of a modal, set this to [SupaAvatarEditorType.dialog] + final SupaAvatarEditorType editorType; + + @override + State createState() => _SupaAvatarEditorState(); +} + +class _SupaAvatarEditorState extends State { + late String _cacheBuster; + bool _isLoading = false; + File? _lastAvatarFile; + + final user = Supabase.instance.client.auth.currentUser; + + @override + void initState() { + super.initState(); + _cacheBuster = + widget.cacheBuster ?? DateTime.now().millisecondsSinceEpoch.toString(); + } + + Future _handleAvatarEdit(BuildContext context) async { + Future?> result; + + Widget supaAvatarEditor = SupaAvatarEditor( + cacheBuster: _cacheBuster, + supabaseStorageBucket: widget.supabaseStorageBucket, + supabaseStoragePath: widget.supabaseStoragePath, + supabaseUserAttributeImageUrlKey: widget.supabaseUserAttributeImageUrlKey, + fallbackIcon: widget.fallbackIcon, + radius: widget.radius, + user: user, + ); + + if (widget.editorType == SupaAvatarEditorType.dialog) { + result = showDialog>( + context: context, + builder: (_) { + return Dialog( + backgroundColor: widget.editorBackgroundColor, + shape: widget.editorShape ?? + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: supaAvatarEditor, + ); + }, + ); + } else { + result = showModalBottomSheet>( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: widget.editorBackgroundColor, + shape: widget.editorShape ?? + const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => supaAvatarEditor); + } + + final resolved = await result; + if (resolved == null || user == null) return; + + final file = resolved['file'] as File?; + final remove = resolved['remove'] == true; + + setState(() => _isLoading = true); + + try { + if (remove) { + await _removeAvatar(user!.id); + _lastAvatarFile = null; + _showSnackbar(context, 'Avatar removed successfully'); + } else if (file != null) { + await _uploadAvatar(file, user!.id); + _lastAvatarFile = file; + _showSnackbar(context, 'Avatar updated successfully'); + } + + setState(() { + _cacheBuster = DateTime.now().millisecondsSinceEpoch.toString(); + }); + } catch (e) { + _showSnackbar(context, 'Something went wrong 😢', isError: true); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + void _showSnackbar( + BuildContext context, + String message, { + bool isError = false, + }) { + final theme = Theme.of(context); + final bgColor = isError + ? widget.snackBarErrorBackgroundColor ?? + theme.colorScheme.errorContainer + : widget.snackBarBackgroundColor ?? + theme.colorScheme.secondaryContainer; + final textColor = isError + ? widget.snackBarErrorTextColor ?? theme.colorScheme.onErrorContainer + : widget.snackBarTextColor ?? theme.colorScheme.onSecondaryContainer; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: bgColor, + action: SnackBarAction( + label: 'OK', + textColor: textColor, + onPressed: () { + ScaffoldMessenger.of(context).clearSnackBars(); + }, + ), + duration: widget.snackBarDuration ?? const Duration(milliseconds: 4000), + content: Text(message, style: TextStyle(color: textColor)), + ), + ); + } + + Future _uploadAvatar(File file, String userId) async { + final fileBytes = await file.readAsBytes(); + final mimeType = lookupMimeType(file.path) ?? 'image/jpeg'; + final storagePath = '$userId/${widget.supabaseStoragePath}'; + + await Supabase.instance.client.storage + .from(widget.supabaseStorageBucket) + .uploadBinary( + storagePath, + fileBytes, + fileOptions: FileOptions(upsert: true, contentType: mimeType), + ); + + final publicUrl = Supabase.instance.client.storage + .from(widget.supabaseStorageBucket) + .getPublicUrl(storagePath); + + await Supabase.instance.client.auth.updateUser( + UserAttributes( + data: {widget.supabaseUserAttributeImageUrlKey: publicUrl}, + ), + ); + } + + Future _removeAvatar(String userId) async { + final bucket = Supabase.instance.client.storage.from( + widget.supabaseStorageBucket, + ); + + final files = await bucket.list(path: userId); + for (final file in files) { + await bucket.remove(['$userId/${file.name}']); + } + + await Supabase.instance.client.auth.updateUser( + UserAttributes(data: {widget.supabaseUserAttributeImageUrlKey: null}), + ); + } + + @override + Widget build(BuildContext context) { + final avatarWidget = _lastAvatarFile != null + ? CircleAvatar( + radius: widget.radius, + backgroundImage: FileImage(_lastAvatarFile!), + ) + : SupaUserAvatar( + radius: widget.radius, + cacheBuster: _cacheBuster, + key: ValueKey(_cacheBuster), + fallbackIcon: widget.fallbackIcon, + supabaseStorageBucket: widget.supabaseStorageBucket, + supabaseStoragePath: widget.supabaseStoragePath, + supabaseUserAttributeImageUrlKey: + widget.supabaseUserAttributeImageUrlKey, + ); + return GestureDetector( + onTap: widget.isEditable + ? _isLoading + ? null + : () => _handleAvatarEdit(context) + : null, + child: Stack( + alignment: Alignment.center, + children: [ + avatarWidget, + if (_isLoading) + Container( + width: widget.radius * 2, + height: widget.radius * 2, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary.withValues( + alpha: 0.54, + ), + shape: BoxShape.circle, + ), + child: Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onPrimary), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/components/supa_avatar/widgets/supa_avatar_editor.dart b/lib/src/components/supa_avatar/widgets/supa_avatar_editor.dart new file mode 100644 index 0000000..098796d --- /dev/null +++ b/lib/src/components/supa_avatar/widgets/supa_avatar_editor.dart @@ -0,0 +1,149 @@ +// 📁 supa_avatar_modal.dart +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:supabase_auth_ui/src/components/supa_avatar/widgets/supa_user_avatar.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:ui_avatar/ui_avatar.dart'; + +class SupaAvatarEditor extends StatefulWidget { + final String? cacheBuster; + final String supabaseStorageBucket; + final String supabaseStoragePath; + final String supabaseUserAttributeImageUrlKey; + final Widget fallbackIcon; + final double radius; + final User? user; + + const SupaAvatarEditor({ + super.key, + this.cacheBuster, + required this.supabaseStorageBucket, + required this.supabaseStoragePath, + required this.supabaseUserAttributeImageUrlKey, + required this.fallbackIcon, + required this.radius, + this.user, + }); + + @override + State createState() => _SupaAvatarModalState(); +} + +class _SupaAvatarModalState extends State { + File? _localImageFile; + bool _removeRequested = false; + final _picker = ImagePicker(); + + void _removeImage() { + if (_localImageFile == null && _removeRequested) return; + setState(() { + _localImageFile = null; + _removeRequested = true; + }); + } + + Future _pickImage(ImageSource source) async { + final picked = await _picker.pickImage(source: source); + if (picked == null) return; + setState(() { + _localImageFile = File(picked.path); + _removeRequested = false; + }); + } + + void _onSaveChanges() { + Navigator.of( + context, + ).pop({'file': _localImageFile, 'remove': _removeRequested}); + } + + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isChanged = _removeRequested || _localImageFile != null; + final String name = + widget.user?.userMetadata?['username'] ?? widget.user?.email ?? ''; + + final avatar = _localImageFile != null + ? CircleAvatar( + radius: widget.radius, + backgroundImage: FileImage(_localImageFile!), + ) + : _removeRequested + ? name.isNotEmpty + ? UiAvatar( + name: name, + size: widget.radius * 2, + useRandomColors: true, + ) + : CircleAvatar( + radius: widget.radius, child: widget.fallbackIcon) + : SupaUserAvatar( + radius: widget.radius, + cacheBuster: widget.cacheBuster, + fallbackIcon: widget.fallbackIcon, + supabaseStorageBucket: widget.supabaseStorageBucket, + supabaseStoragePath: widget.supabaseStoragePath, + supabaseUserAttributeImageUrlKey: + widget.supabaseUserAttributeImageUrlKey, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + + children: [ + const Text( + "Update User Avatar", + style: TextStyle( + fontSize: 20, + ), + ), + const SizedBox(height: 16), + avatar, + const SizedBox(height: 24), + ListTile( + onTap: () => _pickImage(ImageSource.gallery), + leading: const Icon(Icons.photo), + title: const Text("Choose from gallery"), + ), + ListTile( + onTap: () => _pickImage(ImageSource.camera), + leading: const Icon(Icons.camera_alt), + title: const Text("Take photo"), + ), + + ListTile( + onTap: (_localImageFile == null && _removeRequested) + ? null + : _removeImage, + leading: Icon(Icons.delete, color: theme.colorScheme.error), + title: Text( + "Remove avatar", + style: TextStyle(color: theme.colorScheme.error), + ), + ), + + + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + onPressed: isChanged ? _onSaveChanges : null, + child: const Text( + "Save changes", + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/components/supa_avatar/widgets/supa_user_avatar.dart b/lib/src/components/supa_avatar/widgets/supa_user_avatar.dart new file mode 100644 index 0000000..0237b78 --- /dev/null +++ b/lib/src/components/supa_avatar/widgets/supa_user_avatar.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:supabase_auth_ui/supabase_auth_ui.dart'; +import 'package:ui_avatar/ui_avatar.dart'; + +class SupaUserAvatar extends StatelessWidget { + const SupaUserAvatar({ + super.key, + this.radius = 32, + this.fallbackIcon = const Icon(Icons.person), + this.cacheBuster, + this.supabaseStorageBucket = 'avatars', + this.supabaseStoragePath = 'profile', + this.supabaseUserAttributeImageUrlKey = 'avatar_url', + }); + + final double radius; + final Widget fallbackIcon; + final String? cacheBuster; + final String supabaseStorageBucket; + final String supabaseStoragePath; + final String supabaseUserAttributeImageUrlKey; + + @override + Widget build(BuildContext context) { + final user = Supabase.instance.client.auth.currentUser; + if (user == null) { + return CircleAvatar(radius: radius, child: fallbackIcon); + } + + final avatarUrl = user.userMetadata?[supabaseUserAttributeImageUrlKey] as String?; + if (avatarUrl != null && avatarUrl.startsWith('http')) { + final bustedUrl = _appendCacheBuster(avatarUrl); + return CircleAvatar( + radius: radius, + foregroundImage: NetworkImage(bustedUrl), + child: _buildUiAvatar(user), + ); + } + + final publicUrl = Supabase.instance.client.storage + .from(supabaseStorageBucket) + .getPublicUrl('${user.id}/$supabaseStoragePath'); + + final bustedUrl = _appendCacheBuster(publicUrl); + return CircleAvatar( + radius: radius, + foregroundImage: NetworkImage(bustedUrl), + child: _buildUiAvatar(user), + ); + } + + String _appendCacheBuster(String url) { + if (cacheBuster == null) return url; + return '$url?cb=$cacheBuster'; + } + + Widget _buildUiAvatar(User user) { + final name = user.userMetadata?['username'] ?? user.email ?? ''; + if (name.isNotEmpty) { + return UiAvatar(name: name, size: radius * 2, useRandomColors: true,); + } + return fallbackIcon; + } +} diff --git a/lib/supabase_auth_ui.dart b/lib/supabase_auth_ui.dart index 4bcd043..2ec9ac6 100644 --- a/lib/supabase_auth_ui.dart +++ b/lib/supabase_auth_ui.dart @@ -6,6 +6,7 @@ export 'src/components/supa_reset_password.dart'; export 'src/components/supa_socials_auth.dart'; export 'src/components/supa_phone_auth.dart'; export 'src/components/supa_verify_phone.dart'; +export 'src/components/supa_avatar/supa_avatar.dart'; export 'src/utils/supa_auth_action.dart'; export 'src/localizations/supa_email_auth_localization.dart'; export 'src/localizations/supa_magic_auth_localization.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index ea198fd..8909bd6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,9 @@ dependencies: google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0 crypto: ^3.0.3 + mime: ^2.0.0 + image_picker: ^1.1.2 + ui_avatar: ^0.0.2 dev_dependencies: flutter_test: diff --git a/screenshots/avatar.gif b/screenshots/avatar.gif new file mode 100644 index 0000000..80c8838 Binary files /dev/null and b/screenshots/avatar.gif differ diff --git a/screenshots/avatar.png b/screenshots/avatar.png new file mode 100644 index 0000000..501bce9 Binary files /dev/null and b/screenshots/avatar.png differ diff --git a/screenshots/dialog-editor.png b/screenshots/dialog-editor.png new file mode 100644 index 0000000..37ac9aa Binary files /dev/null and b/screenshots/dialog-editor.png differ diff --git a/screenshots/modal-editor.png b/screenshots/modal-editor.png new file mode 100644 index 0000000..878de7b Binary files /dev/null and b/screenshots/modal-editor.png differ