Authentication in Flutter with Supabase (OAuth included)

Authentication in Flutter with Supabase (OAuth included)

I’ve been working on a Flutter app for a while and recently decided to add authentication. Setting it up correctly is challenging, especially when it comes to managing backend infrastructure and administration. So, there are several common approaches to implementing authentication, including:

  1. Building a custom authentication service
  2. Using a cloud-based service like Auth0, Firebase, or Okta
  3. Deploying a self-hosted solution like Keycloak or Supabase

After carefully evaluating these options, I realized that creating a custom authentication service and managing the infrastructure would consume a lot of time — something I wanted to avoid, especially for an MVP.

This led me to look at pre-built solutions. I initially considered Firebase, Auth0, and Clerk, but found their pricing prohibitive and noticed that some aren’t fully supported in Flutter (for example, Firebase isn’t available on Desktop).

Then, I discovered Supabase, an open-source Firebase alternative that is written in pure Dart. In this article, I’ll cover how to implement Authentication (with OAuth via Google) using Supabase, along with setting up a robust architecture that avoids tight coupling. This is going to be useful even if you do not use Supabase as I’ll walk through creating guards, an authentication data provider, and a BLoC to manage the flow.

Outline

  1. Auth Provider
    1. Session and User
    2. Implementation for Supabase
  2. Auth Bloc and Scope
    1. Initialization
    2. Auth Scope
  3. Navigation
    1. Login and Signup Screens
    2. Routes
    3. Guards

Auth Provider

Our journey starts with the data provider, the foundation of the app’s architecture. The data layer handles data exchange, specifically in this case, by communicating with Supabase. If you're not familiar with layered architecture, check out my Flutter App Architecture series.

Here’s the AuthProvider interface:

/// {@template auth_provider}
/// Provides methods to authenticate users.
/// {@endtemplate}
abstract interface class AuthProvider {
  /// Stream of the session state.
  ///
  /// If session is null, user is not authenticated.
  Stream<SessionEntity?> get sessionStream;

  /// Loads the session.
  Future<SessionEntity?> loadSession();

  /// Sign in with Google.
  ///
  /// The event is delivered later in [sessionStream], when user comes back from the Google sign in process.
  Future<void> signInWithGoogle();

  /// Sign up with email and password.
  ///
  /// The event is also delivered later in [sessionStream], when user is signed up.
  Future<SessionEntity?> signUpWithEmailAndPassword(String email, String password);

  /// Sign in with email and password.
  ///
  /// The event is also delivered later in [sessionStream], when user is signed in.
  Future<SessionEntity?> signInWithEmailAndPassword(String email, String password);

  /// Refreshes the session.
  Future<SessionEntity?> refreshSession();

  /// Sign out.
  Future<void> signOut();
}

AuthProvider interface

The AuthProvider interface includes methods needed for authentication management in the app. Here’s a breakdown:

  • sessionStream: Returns a stream of session changes, notifying listeners whenever the authentication state updates. For instance, a successful sign-in emits a populated session model, indicating the user is authenticated and can access protected resources.
  • loadSession: Loads a persisted session, typically used during app restarts, in authentication request interceptors, or for refreshing tokens.
  • refreshSession: Retrieves a new session from the current one. Each session includes an access token and a refresh token. When the access token expires, the refresh token is used to renew it.
  • signInWithGoogle: Handles signing in with Google. It returns void because the OAuth process requires opening an external browser, so the session is updated asynchronously when the user returns to the app.
  • signUpWithEmailAndPassword and signInWithEmailAndPassword: Create an account and log in using an email-password pair.
  • signOut: Signs the user out and invalidates their tokens.

Session and User

To avoid being tightly coupled to a specific service or implementation, it’s essential to create custom models abstracted from service-specific ones. Here are some example implementations for our use case:

/// {@template session_entity}
/// Entity of a session.
/// {@endtemplate}
class SessionEntity {
  /// {@macro session_entity}
  SessionEntity({
    required this.accessToken,
    required this.refreshToken,
    required this.user,
  });

  /// Access token.
  final String accessToken;

  /// Refresh token.
  final String? refreshToken;

  /// User entity.
  final UserEntity user;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is SessionEntity &&
          runtimeType == other.runtimeType &&
          accessToken == other.accessToken &&
          refreshToken == other.refreshToken &&
          user == other.user;

  @override
  int get hashCode => accessToken.hashCode ^ refreshToken.hashCode ^ user.hashCode;
}

/// {@template user_entity}
/// Entity of authenticated user.
/// {@endtemplate}
class UserEntity {
  /// {@macro user_entity}
  UserEntity({
    required this.id,
    this.email,
  });

  /// User id.
  final String id;

  /// User email.
  final String? email;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is UserEntity &&
          runtimeType == other.runtimeType &&
          id == other.id &&
          email == other.email;

  @override
  int get hashCode => id.hashCode ^ email.hashCode;

  @override
  String toString() => 'UserEntity(id: $id, email: $email)';
}

Session and User models

Implementation for Supabase

With the interface and models in place, we can now create an implementation that uses Supabase for authentication. Here’s how I implemented it:

/// {@template auth_provider_supabase}
/// AuthProvider implementation for Supabase.
/// {@endtemplate}
final class AuthProviderSupabase implements AuthProvider {
  /// {@macro auth_provider_supabase}
  AuthProviderSupabase({
    required this.client,
    required this.oauthRedirectUri,
  });

  /// Supabase Client instance.
  final supabase.SupabaseClient client;

  /// OAuth redirect URI.
  final String oauthRedirectUri;

  @override
  Stream<SessionEntity?> get sessionStream => client.auth.onAuthStateChange.map(
        (event) {
          final session = _mapSessionToEntity(event.session);

          return session;
        },
      );

  @override
  Future<SessionEntity?> loadSession() async {
    final session = client.auth.currentSession;

    return _mapSessionToEntity(session);
  }

  @override
  Future<void> signInWithGoogle() async {
    final oAuthUrl = await client.auth.getOAuthSignInUrl(
      provider: supabase.OAuthProvider.google,
      redirectTo: oauthRedirectUri,
    );

    await launchUrl(Uri.parse(oAuthUrl.url));
  }

  @override
  Future<SessionEntity?> signInWithEmailAndPassword(String email, String password) async {
    try {
      final response = await client.auth.signInWithPassword(
        email: email,
        password: password,
      );

      return _mapSessionToEntity(response.session);
    } on supabase.AuthException catch (e, stackTrace) {
      final exception = _mapAuthException(e);

      Error.throwWithStackTrace(exception, stackTrace);
    }
  }

  @override
  Future<SessionEntity?> signUpWithEmailAndPassword(String email, String password) async {
    try {
      final response = await client.auth.signUp(email: email, password: password);

      return _mapSessionToEntity(response.session);
    } on supabase.AuthException catch (e, stackTrace) {
      final exception = _mapAuthException(e);

      Error.throwWithStackTrace(exception, stackTrace);
    }
  }

  @override
  Future<void> signOut() async => await client.auth.signOut();

  @override
  Future<SessionEntity?> refreshSession() async {
    final response = await client.auth.refreshSession();

    return _mapSessionToEntity(response.session);
  }
}

AuthProviderSupabase implementation

The AuthProviderSupabase class implements the AuthProvider interface using Supabase. It maps Supabase-specific models to application-wide entities:

SessionEntity? _mapSessionToEntity(supabase.Session? session) {
  if (session == null) return null;

  return SessionEntity(
    accessToken: session.accessToken,
    refreshToken: session.refreshToken,
    user: UserEntity(
      id: session.user.id,
      email: session.user.email,
    ),
  );
}

Another responsibility of the data layer is to map service-specific exceptions to application-recognized ones. In AuthProviderSupabase, Supabase’s AuthException is caught and converted into an AuthException that the app understands:

AuthException _mapAuthException(supabase.AuthException e) => switch (e.code) {
      'invalid_credentials' => AuthExceptionInvalidCredentials(message: e.message),
      'validation_failed' => AuthExceptionValidationFailed(message: e.message),
      'weak_password' => AuthExceptionWeakPassword(message: e.message),
      'user_already_exists' => AuthExceptionUserAlreadyExists(message: e.message),
      _ => AuthExceptionUnknown(message: '${e.message} (${e.code})', cause: e),
    };

This class is straightforward, but there’s one notable aspect: the signInWithGoogle method. Unlike the others, it opens a browser app for the user to log into their Google account. Once the login is successful, the user is redirected back to the app via a deep link containing the token. This behavior is handled by supabase internally (i.e. catching the deep link and notifying about state changes) and in case of success a new session is delivered via the sessionStream.

At this point you may set up Google Sign-In, as it requires configuration on both the mobile and server sides. These links might help:

Login with Google | Supabase Docs
Use Sign in with Google on the web, in native apps or with Chrome extensions
Getting started with Flutter authentication
Learn how authentication on Flutter works through Google sign in with Supabase auth.
Native Mobile Deep Linking | Supabase Docs
Set up Deep Linking for mobile applications.

Auth Bloc and Scope

Now, it’s time to implement the business logic and state management. I’ll use bloc for this, which will handle communication with the AuthProvider and update the state accordingly. This is how it looks like:

/// {@template auth_bloc}
/// Authentication bloc.
/// {@endtemplate}
final class AuthBloc extends Bloc<AuthEvent, AuthState> {
  /// Creates an authentication bloc.
  AuthBloc({
    required AuthProvider authProvider,
    required AuthState initialState,
  })  : _authProvider = authProvider,
        super(initialState) {
    on<AuthEvent>(
      (event, emit) => switch (event) {
        final AuthEventSignInWithGoogle event => _signInWithGoogle(event, emit),
        final AuthEventSignInWithEmailAndPassword event => _signInWithEmailAndPassword(event, emit),
        final AuthEventSignUpWithEmailAndPassword event => _signUpWithEmailAndPassword(event, emit),
        final UpdateSessionEvent event => _updateSession(event, emit),
        final AuthEventSignOut event => _signOut(event, emit),
      },
    );

    // Each time when session changes, update the session state
    _authProvider.sessionStream.listen((session) {
      add(UpdateSessionEvent(session));
    });
  }

  final AuthProvider _authProvider;

  Future<void> _signInWithGoogle(
    AuthEventSignInWithGoogle event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthProcessingState(event: event, session: state.session));

    try {
      await _authProvider.signInWithGoogle();
      emit(AuthIdleState(event: event, session: state.session));
    } on Object catch (error, stackTrace) {
      emit(AuthErrorState(error: error, event: event, session: state.session));
      onError(error, stackTrace);
    } finally {
      emit(AuthIdleState(event: event, session: state.session));
    }
  }

  Future<void> _signInWithEmailAndPassword(
    AuthEventSignInWithEmailAndPassword event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthProcessingState(event: event, session: state.session));

    try {
      final newSession = await _authProvider.signInWithEmailAndPassword(
        event.email,
        event.password,
      );
      emit(AuthIdleState(event: event, session: newSession));
    } on AuthException catch (error) {
      emit(AuthErrorState(error: error, event: event, session: state.session));
    } on Object catch (error, stackTrace) {
      emit(AuthErrorState(error: error, event: event, session: state.session));
      onError(error, stackTrace);
    } finally {
      emit(AuthIdleState(event: event, session: state.session));
    }
  }

  Future<void> _updateSession(
    UpdateSessionEvent event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthProcessingState(event: event, session: state.session));

    try {
      emit(AuthIdleState(event: event, session: event.session));
    } on Object catch (error, stackTrace) {
      emit(AuthErrorState(error: error, event: event, session: state.session));
      onError(error, stackTrace);
    } finally {
      emit(AuthIdleState(event: event, session: state.session));
    }
  }
}

AuthBloc implementation

This bloc includes handlers for each authentication method from the AuthProvider. Each handler uses try/catch blocks to catch potential exceptions. If an exception arises, it checks for known (application-wide) exceptions and doesn’t report them, as they’re expected and considered normal behavior.

There’s also a special event called UpdateSessionEvent, used internally by the bloc when a session update happens. The _updateSession handler simply emits the new session state.

Initialization

Authentication is a feature best initialized at app startup, allowing the app to know right away whether the user is authenticated or not. In my Sizzle Starter template I have an established approach for initializing dependencies. Take a look here.

The initialization factory for the bloc would look like this:

/// A factory class that creates [AuthBloc]
class AuthBlocFactory extends AsyncFactory<AuthBloc> {
  /// Creates an instance of [AuthBlocFactory].
  AuthBlocFactory({
    required this.sharedPreferences,
    required this.supabaseClient,
    required this.config,
  });

  /// Shared preferences instance.
  final SharedPreferencesAsync sharedPreferences;

  /// Supabase client instance.
  final supabase.SupabaseClient supabaseClient;

  /// Application configuration
  final Config config;

  @override
  Future<AuthBloc> init() async {
    final authProvider = AuthProviderSupabase(
      client: supabaseClient,
      oauthRedirectUri: config.oauthAppRedirectUri,
    );

    final session = await authProvider.loadSession();

    return AuthBloc(
      authProvider: authProvider,
      initialState: AuthIdleState(session: session),
    );
  }
}

In addition to creating the bloc instance, it loads the session and sets the initial state. This ensures that if the user is already authenticated, they go straight into the app upon startup, skipping any unnecessary loading screens.

Auth Scope

To create a cleaner API for handling authentication and to prepare for future navigation logic, we need to set up a scope that uses the auth bloc under the hood:

class AuthScope extends StatefulWidget {
  /// {@macro auth_scope}
  const AuthScope({
    required this.child,
    super.key, // ignore: unused_element
  });

  /// The widget below this widget in the tree.
  final Widget child;

  /// Returns the current [AuthScopeState].
  static AuthScopeState of(
    BuildContext context, {
    bool listen = true,
  }) =>
      context.inhOf<_AuthInherited>(listen: listen).state;

  /// Returns the bloc instance.
  static AuthBloc blocOf(BuildContext context, {bool listen = true}) =>
      of(context, listen: listen).authBloc;

  /// Returns the current [AuthState].
  static AuthState stateOf(BuildContext context, {bool listen = true}) =>
      of(context, listen: listen)._authState;

  @override
  State<AuthScope> createState() => AuthScopeState();
}

/// State for widget AuthScope.
class AuthScopeState extends State<AuthScope> {
  StreamSubscription<AuthState>? _subscription;

  /// The auth bloc instance
  late final AuthBloc authBloc;

  /// Auth state.
  AuthState get authState => _authState;

  late AuthState _authState;

  @override
  void initState() {
    super.initState();
    // get the initialized auth bloc instance
    authBloc = DependenciesScope.of(context).authBloc;
    _authState = authBloc.state;
    _subscription = authBloc.stream.listen((state) {
      setState(() => _authState = state);
    });
  }

  @override
  void dispose() {
    _subscription?.cancel();
    super.dispose();
  }

  /* Helper Methods */
  
  /// Signs in with Google.
  void signInWithGoogle() => authBloc.add(const AuthEventSignInWithGoogle());

  /// Signs out.
  void signOut() => authBloc.add(const AuthEventSignOut());

  @override
  Widget build(BuildContext context) => _AuthInherited(
        state: this,
        authState: _authState,
        child: widget.child,
      );
}

/// {@template auth_inherited}
/// _AuthInherited widget.
/// {@endtemplate}
class _AuthInherited extends InheritedWidget {
  /// {@macro auth_inherited}
  const _AuthInherited({
    required this.state,
    required this.authState,
    required super.child,
    super.key, // ignore: unused_element
  });

  final AuthScopeState state;
  final AuthState authState;

  @override
  bool updateShouldNotify(covariant _AuthInherited oldWidget) {
    final shouldNotify = authState != oldWidget.authState;

    return shouldNotify;
  }
}

This scope holds the authentication state and provides helper methods, allowing widgets to avoid dealing with bloc specifics. Clients of AuthScope can access the AuthBloc using blocOf, the authentication state with stateOf, and the scope’s state through the of method. Here's how we can use this scope:

final authState = AuthScope.stateOf(context);
print(authState?.session != null ? "authenticated" : "not authenticated");

final bloc = AuthScope.blocOf(context);
bloc.listen(); // listen for changes ...

The last step is to wrap the root of your application with AuthScope.

In this section we're going to implement the navigation between screens and secure them with guards.

Login and Signup Screens

I’ve created two authentication screens for the app: Login and Signup. The Login screen includes text fields for email and password, along with buttons to sign in using either email/password or Google:

/// {@template login_screen}
/// LoginScreen widget.
/// {@endtemplate}
class LoginScreen extends StatefulWidget {
  /// {@macro login_screen}
  const LoginScreen({
    super.key, // ignore: unused_element
  });

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  bool _obscuringPassword = true;

  late final _emailController = TextEditingController();
  late final _passwordController = TextEditingController();

  late final _authBloc = AuthScope.blocOf(context, listen: false);

  late final _formListenable = Listenable.merge([
    _emailController,
    _passwordController,
  ]);

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  void _signInWithGoogle() => AuthScope.of(context, listen: false).signInWithGoogle();
  void _signInWithEmailAndPassword() =>
      AuthScope.of(context, listen: false).signInWithEmailAndPassword(
        email: _emailController.text,
        password: _passwordController.text,
      );

  String _localizeError(Object error) => switch (error) {
        final AuthException authException => localizeAuthException(context, authException),
        _ => Localization.of(context).unknown_exception_happened(error.toString()),
      };

  String? _errorText;

  @override
  Widget build(BuildContext context) {
    final layout = WindowSizeScope.of(context);
    final l10n = Localization.of(context);

    return BlocConsumer<AuthBloc, AuthState>(
      bloc: _authBloc,
      listener: (context, state) {
        if (state.error case final Object error
            when state.event is AuthEventSignInWithEmailAndPassword) {
          _errorText = _localizeError(error);
        }
      },
      builder: (context, authState) => Scaffold(
        backgroundColor: Theme.of(context).colorScheme.surface,
        body: Center(
          child: SingleChildScrollView(
            padding: HorizontalSpacing.centered(layout.width, 512).copyWith(
              top: 32,
              bottom: 32,
            ),
            child: SizedBox(
              width: 512,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  UiText.headlineSmall(l10n.sign_in_to_your_account),
                  const SizedBox(height: 24),
                  UiTextField.standard(
                    controller: _emailController,
                    keyboardType: TextInputType.emailAddress,
                    style: UiTextFieldStyle(
                      labelText: l10n.email,
                      hintText: l10n.enter_email,
                      prefixIcon: const Icon(Icons.email),
                    ),
                  ),
                  const SizedBox(height: 16),
                  UiTextField.standard(
                    controller: _passwordController,
                    obscureText: _obscuringPassword,
                    keyboardType: TextInputType.visiblePassword,
                    style: UiTextFieldStyle(
                      labelText: l10n.password,
                      hintText: l10n.enter_password,
                      prefixIcon: const Icon(Icons.lock),
                      suffixIcon: UiButton.icon(
                        icon: Icon(_obscuringPassword ? Icons.visibility : Icons.visibility_off),
                        onPressed: () {
                          setState(() => _obscuringPassword = !_obscuringPassword);
                        },
                      ),
                    ),
                  ),
                  if (_errorText case final String error) ...[
                    const SizedBox(height: 8),
                    Align(
                      alignment: Alignment.centerLeft,
                      child: UiText.bodyMedium(
                        error,
                        color: Theme.of(context).colorScheme.error,
                      ),
                    ),
                  ],
                  const SizedBox(height: 16),
                  ListenableBuilder(
                    listenable: _formListenable,
                    builder: (context, _) => SizedBox(
                      height: 48,
                      width: double.infinity,
                      child: UiButton.filledPrimary(
                        label: authState.isProcessing
                            ? const UiProgressIndicator()
                            : Text(l10n.sign_in),
                        enabled: _emailController.text.isNotEmpty &&
                            _passwordController.text.isNotEmpty &&
                            !authState.isProcessing,
                        onPressed: _signInWithEmailAndPassword,
                      ),
                    ),
                  ),
                  if (Theme.of(context).platform != TargetPlatform.windows) ...[
                    const SizedBox(height: 24),
                    UiButton.text(
                      icon: const Icon(Icons.g_mobiledata, size: 48),
                      label: Text(l10n.sign_in_with_google),
                      onPressed: _signInWithGoogle,
                    ),
                  ],
                  const UiDivider(height: 32),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      UiText.bodyMedium(l10n.don_t_have_an_account),
                      const SizedBox(width: 8),
                      UiButton.text(
                        label: Text(l10n.sign_up),
                        onPressed: () {
                          const SignupRoute().go(context);
                        },
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

LoginScreen

The login screen includes basic validation and uses a scope to send appropriate login events. It listens for state changes in bloc and optionally displays errors. What’s interesting is how errors are localized: I’ve created a method that returns a specific error message string based on the type of exception:

/// Localizes the [AuthException].
String localizeAuthException(BuildContext context, AuthException exception) {
  final l10n = Localization.of(context);

  return switch (exception) {
    AuthExceptionInvalidCredentials _ => l10n.invalid_credentials_entered,
    AuthExceptionValidationFailed _ => l10n.validation_failed,
    AuthExceptionWeakPassword _ => l10n.weak_password,
    AuthExceptionUserAlreadyExists _ => l10n.user_already_exists,
    final AuthExceptionUnknown exception => l10n.exception_happened(
      '${exception.message}',
    ),
  };
}

Routes

The signup screen is similar to the login screen. Now, we need to set up the routes. I’ll use go_router for this, but feel free to use any routing package you prefer:

/// {@template login_route}
/// A route for the login screen.
/// {@endtemplate}
@TypedGoRoute<LoginRoute>(path: '/login')
class LoginRoute extends GoRouteData {
  /// {@macro login_route}
  const LoginRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const LoginScreen();
}

/// {@template signup_route}
/// A route for the signup screen.
/// {@endtemplate}
@TypedGoRoute<SignupRoute>(path: '/signup')
class SignupRoute extends GoRouteData {
  /// {@macro signup_route}
  const SignupRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const SignupScreen();
}

Guards

Since everything is set up, let’s add authentication guards:

final _authMatchPattern =
    RegExp('^(${const LoginRoute().location}|${const SignupRoute().location})\$');

/// A guard that redirects users from the login screen if they are authenticated.
final class RedirectIfAuthenticatedGuard extends RedirectGuard {
  @override
  Pattern get matchPattern => _authMatchPattern;

  @override
  String? redirect(BuildContext context, GoRouterState state) {
    final isAuthenticated = AuthScope.stateOf(context).session != null;

    if (isAuthenticated) {
      return const GenerateRoute().location;
    }

    return null;
  }
}

/// A guard that redirects users to the login screen if they are not authenticated.
final class RedirectIfNotAuthenticatedGuard extends RedirectGuard {
  @override
  Pattern get matchPattern => _authMatchPattern;

  @override
  bool get invertMatch => true;

  @override
  String? redirect(BuildContext context, GoRouterState state) {
    final isAuthenticated = AuthScope.stateOf(context).session != null;

    if (!isAuthenticated) {
      return const LoginRoute().location;
    }

    return null;
  }
}

The RedirectIfAuthenticatedGuard should redirect any authenticated users to the "/generate" route, which serves as the home route of the application.

The RedirectIfNotAuthenticatedGuard should redirect unauthenticated users to the "/login" route when they try to access secure routes. For example, if a user tries to access "/generate" without being authenticated, they will be redirected to "/login".

The guards monitor changes in authentication state and trigger the appropriate redirects. This works because AuthScope relies on an inherited widget, creating a dependency that causes the guards to re-evaluate whenever the auth state changes. For example, if an unauthenticated user successfully signs in and the auth BLoC emits a state with an active session, RedirectIfAuthenticatedGuard is notified by the inherited widget and returns the "/generate" path, redirecting the user to the generate screen after login.

The sources of RedirectGuard and RedirectBuilder can be found here.

Final Results

Supabase looks very promising so far. I’ll see how it performs when I create my own backend routes and secure them with Supabase tokens. I’ll keep you posted—stay tuned!

By the way, this is how the final result looks like: