Thank you for sticking around throughout this series of codelabs around Flutter Web.

NOTE: THIS IS NOT A BEGINNER CODELAB

(If you're looking for more beginner content, please visit some of my other codelabs here). If you're up for the challenge, then proceed.

This is the second of a codelab series focused on Flutter Web. Follow this link for part one of this series, where we learned how to set up our project and lay down some foundational items.

In Part 2, you will continue building the Flutter portfolio web app and learn the following:

Let's proceed!!

We need to use a solution to manage the reactive data binding and caching of our applicaton's data, as well as use it as our de facto state management strategy for this web app.

The best approach is to separate the data from the UI rendering logic, and for that we'll review state management - a very hot and debated topic in the Flutter community.

State Management is how we organize our app to most effectively access state and share it across widgets. State Management has two goals:

State Management then becomes the guardrail layer in between that gives us the ability to do both of those things above. Plain and simple.

We'll use one of the more popular packages called Riverpod which encapsulates the best of all worlds: dependency injection, service location, reactive caching, and more.

The state management approach of Riverpod revolves around providers - objects that encapsulate a piece of state and allows listening to that state. We will create providers that will encapsulate the data for each of our features, as well as for the data that populates the navigation items and state of each of those navigation items (selected or not selected).

Install Riverpod

Before proceeding any further, you must install the Riverpod package, available via pub.dev by running the following command on your terminal:

flutter pub add flutter_riverpod

This will install the Riverpod package to use it in Flutter, and add the required dependencies on your pubspec.yaml.

Add the required import at the top of the main.dart file:

import 'package:flutter_riverpod/flutter_riverpod.dart';

Next, wrap the root of the app (the PortfolioApp instance) injected via the runApp method inside a ProviderScope widget; this is a Riverpod construct that must be added at the root of your Flutter application for Riverpod to work.

void main() {
  runApp(
    const ProviderScope(
      child: PortfolioApp()
    )
  );
}

With this in place, we need to define some data models to support that notion, and we'll start with the navigation feature. Hit Next to proceed.

In the features folder, create a subfolder called navigation, and create the following structure under it:

Let's start by creating PODO (Plain Ol' Dart Object) classes that will hold the data for our navigation items.

In the navigation/data/models/left_navigation_item.dart file, add the following class; this represents the data model for a single navigation item.

We need to capture the following:

Add the code below to the file in question:

import 'package:flutter/material.dart';

class LeftNavigationItem {
  final IconData icon;
  final String label;
  final String route;
  final bool isSelected;

  LeftNavigationItem({
    required this.icon,
    required this.label,
    required this.route,
    required this.isSelected
  });

  LeftNavigationItem copyWith({
      IconData? icon,
      String? label,
      String? route,
      bool? isSelected
    }) {
      return LeftNavigationItem(
        icon: icon ?? this.icon, 
        label: label ?? this.label, 
        route: route ?? this.route,
        isSelected: isSelected ?? this.isSelected 
    );
  }
}

Notice the copyWith() method; the primary benefit of using copyWith() is that you don't mutate the original object, but instead return a new object with the same properties as the original, but with the values you specify. This allows you to create applications that are easier to test and easier to maintain as objects themselves don't harbor mutable state.

ADDITIONAL NOTE ON IMMUTABILITY: The model definition above is immutable. Immutable objects provide guarantees that multiple sources do not alter the state of the app at an instant in time. This protection frees up the UI to focus on a single role: to read the state and update the UI elements accordingly. Therefore, you should never modify the UI state in the UI directly, unless the UI itself is the sole source of its data. Violating this principle results in multiple sources of truth for the same piece of information, leading to data inconsistencies and subtle bugs.

Create synthetic data for our navigation items

We need to populate this model with data so we can feed it into our app. We'll use a repository for this; but first we'll define a simple signature for what the actual implementation will look like.

In the navigation/data/repositories/inavigation.repository.dart file, add the following abstract class:

import 'package:roman_web_portfolio/features/navigation/data/models/left_navigation_item.dart';

abstract class INavigationRepository {

  List<LeftNavigationItem> getDefaultNavItems();
}

We define an interface class as abstract so the implementing classes provide their own implementation, and since class declarations are themselves interfaces in Dart, this accomplishes our goal of defining some sort of contact for implementing classes. This will aid us in making this app more testable and lends itself for mocking our data.

We want all implementing repository classes to implement the getDefaultNavItems() method, and should return a list of LeftNavigationItem instances.

Notice how we created a mock_navigation.repository.dart for mock implementations, and a navigation.repository.dart for the real implementation. For now we'll use the mock version since we'll be pulling data locally; later we can pull this data from Firebase.

Go go the navigation/repositories/mock_navigation.repository.dart file and add the following code (add the required imports as needed):

class MockNavigationRepository implements INavigationRepository {
    
  @override
  List<LeftNavigationItem> getDefaultNavItems() {
    return [
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.user,
        label: 'My Profile',
        route: WelcomePage.route,
        isSelected: true
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.twitter,
        label: 'Twitter',
        route: '',
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.linkedin,
        label: 'LinkedIn',
        route: '',
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.web,
        label: 'Web',
        route: '',
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.github,
        label: 'Github',
        route: '',
        isSelected: false
      ),
    ];
  }
}

We'll use this mock implementation to feed this hard-coded list of navigation items to get us going for now. Notice how we only have the WelcomePage.route route set in the route property of the corresponding LeftNavigationItem while the others are empty. As we build the features, we'll come back to this one and add the missing route values.

Create the Navigation Data Providers

Go to the navigation/presentation/providers/navigation_providers.dart and let's add the providers we need.

First you need a provider that will encapsulate an instance that implements INavigationRepository; that's why we'll use the MockNavigationRepository to feed the mocked navigation items.

Add the following code:

//... inside your navigation_providers.dart

final navigationRepositoryProvider = Provider<INavigationRepository>((ref) {
  return MockNavigationRepository();
});

See how we create an instance of the Riverpod Provider, which returns an instance of an entity that encapsulates our read-only data.

Now we need another provider that "reads" this repository (whether mocked or real) and pulls the list of navigation items. Add the following provider under the previous one:

//... also inside your navigation_providers.dart

final navigationItemsProvider = Provider<List<LeftNavigationItem>>((ref) {
  return ref.read(navigationRepositoryProvider).getDefaultNavItems();
});

Notice how using the ref object fed into the Provider's callback we are able to use it and invoke its read() method so we can read other providers available.

Both of them use the Provider provider, since their values will be read-only. In the future we may use other providers that fetch data asynchronously from a backend (i.e. Firebase), but for now this suffices.

We'll need an entity that maintains the state of the list of navigation items as well as handle the currently selected item and perform the necessary actions after tapping one of them.

We'll create a viewmodel class called LeftNavigationViewModel in the navigation/presentation/viewmodels/leftnavigation.viewmodel.dart, which takes two parameters: the list of LeftNavigationItem instances to manage as its state, and a Ref object, so we can use it to read other providers from inside this viewmodel.

Add the following code (plus the required imports):

class LeftNavigationViewModel extends StateNotifier<List<LeftNavigationItem>> {

  final Ref ref;
  LeftNavigationViewModel(List<LeftNavigationItem> items, this.ref) : super([]) {
    state = items;

    var item = state.first;
    selectNavItem(item);
  }

  void selectNavItem(LeftNavigationItem item) {
    
    if (item.route.isNotEmpty) {
      GoRouter.of(Utils.tabNav.currentContext!).go(item.route);
    }

    state = [
      for (var element in state)
       element.copyWith(isSelected: item == element)
    ];
  }
}

Notice how this class extends StateNotifier, an observable class that stores a single immutable state. In this case, the immutable state is the list of LeftNavigationItem instances (denoted by the type of StateNotifier - StateNotifier<List<LeftNavigationItem>>).

We can expose methods on our StateNotifier class to allow other objects to modify its internal state. In our case, we created a method called selectNavItem() which takes the currently selected navigation item instance, checks whether its route property is not empty so as to perform the navigation to the corresponding route, and at the end, it assigns its internal state property to a new value (by generating a new immutable list of updated navigation items) which will automatically notify the listeners and update the UI.

Now let's make use of this viewmodel back in the navigation providers.

Go to the navigation/presentation/providers/navigation_providers.dart and add the following provider:

//... also inside your navigation_providers.dart

final navigationItemsViewModelProvider = StateNotifierProvider<LeftNavigationViewModel, 
  List<LeftNavigationItem>>((ref) {
  var items = ref.read(navigationItemsProvider);
  return LeftNavigationViewModel(items, ref);
});

Notice how we are using another kind of provider - the StateNotifierProvider, suitable for wrapping instances that implement StateNotifier - in our case, the LeftNavigationViewModel. The signature must be:

Inside this provider we read the navigationItemsProvider provider, feed the returned list of items to a new instance of LeftNavigationViewModel along with the injected Ref instance.

We'll then consume this provider inside our UI.

With all the hooks in place (data, repository and providers), let's build out the UI representing the navigation.

We'll build this UI based on the schematics below:

App

Let's go to the navigation/presentation/widgets/left_navigation.dart file and add the following code (with the required imports as usual):

class LeftNavigation extends ConsumerWidget {
  const LeftNavigation({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    var navItems = ref.watch(navigationItemsViewModelProvider);

    return Container(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          colors: [
            Colors.white.withOpacity(0.25),
            Colors.transparent,
          ],
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter
        )
      ),
      padding: const EdgeInsets.all(20),
      child: Column(
        children: List.generate(navItems.length, (index) {

            // TODO: add the LeftNavigationItemTile widgets here
            return const SizedBox.shrink();
          }
        ),
      )

    );
  }
}

Notice how this widget is extending ConsumerWidget; a ConsumerWidget is a widget that is identical to a StatelessWidget with the only difference being that it has an extra parameter on its build method: the ref object. We use this ref object to read or watch providers within the build method.

Check out at the top of the build method how we are "watching" the navigationItemsViewModelProvider, which by just watching it or reading it, returns its state - a list of LeftNavigationItem instances, held in a local variable called navItems.

In our case we are watching it - listening to any changes being assigned to it from inside the viewmodel. Later we'll see how we trigger a state update.

We use this navItems via a List.generate method to feed a Column widget since we want them vertically laid out. The List.generate will churn out navigation items as it iterates through the list via its provided callback.

Let's come back to that callback once we finish this following class. Go to the navigation/presentation/widgets/left_navigation_item_tile.dart file and add class called LeftNavigationItemTitle, also extending ConsumerWidget that takes as a parameter the current LeftNavigationItem in the iteration in a required parameter called item.

Your code should look like this (along with the required imports):

class LeftNavigationItemTile extends ConsumerWidget {

  final LeftNavigationItem item;
  const LeftNavigationItemTile({
    super.key,
    required this.item  
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
      var navItemColor = item.isSelected ? 
        Colors.white : Colors.white.withOpacity(0.25);

      return Container(
        margin: const EdgeInsets.only(top: 20, bottom: 20),
        child: IconButton(
          iconSize: 30,
          icon: Icon(
            item.icon,
            color: navItemColor,
          ),
          onPressed: () {
            ref.read(navigationItemsViewModelProvider.notifier).selectNavItem(item);
          }
        ),
      );
  }
}

We flip the color of the selected icon by just checking the item's isSelected property. Notice how in the onPressed() event of the IconButton we read the navigationItemsViewModelProvider, but we read it in a special way: we call .notifier on it so we don't get its state, but an instance of the viewmodel itself, so we can invoke the methods that it exposes. In our case we want to call the selectNavItem() method, passing into it the current navigation item encapsulated into this widget.

The selectNavItem() method in the viewmodel will intuitively grab this value, trigger a state change by reassigning its internal state property with an immutable version of the list of items (including an immutable version of the navigation item being passed), and triggering a rebuild all the way up to the LeftNavigation parent widget, which changes are trickled down, re-rendering the UI and showing the updated state.

Go back to the LeftNavigation widget, inside the List.generate() callback and add the missing piece (replace the placeholder SizedBox and add the required import) as such:

//... leftnavigation.dart
//... inside the List.generate
//... rest of the code omitted for brevity

children: List.generate(navItems.length, (index) {
        return LeftNavigationItemTile(
            item: navItems[index]
        );
    }
),

With this in place, let's proceed to integrate the navigation into the shell. Hit Next to proceed.

So far so good - but we still can't see anything since we haven't integrated it into the app. Let's make it part of the shell component as the navigation will display always as child pages get swapped in and out.

Let's go to the features/shell/presentation/pages/portfoliomain.page.dart and refactor the build method to look like this:

//... portfoliomain.page.dart
//... rest of the code omitted for brevity
@override
Widget build(BuildContext context) {
    return Scaffold(
    key: Utils.mainScaffold,
    backgroundColor: PersonalPortfolioColors.mainBlue,
    body: Stack(
        children: [
                Center(
                    child: child
                ),
                const Align(
                    alignment: Alignment.centerLeft,
                    child: LeftNavigation(),
                ),
            ],
        )
    );
}

Notice how we created a Stack widget, where the injected views (via the child property) are centered, while the LeftNavigation widget sits aligned at the left of the screen. We also temporarily assigned a color to the Scaffold's background color so we can see the navigation.

Rebuild the app and preview it on your browser, you should be seeing the following:

App

See how the navigation maintains the selected navigation item as we tap up and down the items - this is the power of Riverpod and providers!!

Let's proceed to build out the rest of the feature pages and have placeholders for each of them as we develop them further.

Before we proceed further, we need to have some of the plumbing in place for each one of the features we'll be building for this Flutter web app.

Let's add the following pieces for each of our features; all the following folders should go under the feature folder as subfolders:

import 'package:flutter/material.dart';

class TwitterPage extends StatelessWidget {

  static const String route = "/twitter";
  const TwitterPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Twitter Page!'),
    );
  }
}

import 'package:flutter/material.dart';

class GithubPage extends StatelessWidget {

  static const String route = "/github";
  const GithubPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Github Page!'),
    );
  }
}

import 'package:flutter/material.dart';

class LinkedInPage extends StatelessWidget {

  static const String route = "/linkedin";
  const LinkedInPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('LinkedIn Page!'),
    );
  }
}

import 'package:flutter/material.dart';

class WebPage extends StatelessWidget {

  static const String route = "/web";
  const WebPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Web Page!'),
    );
  }
}

After wrapping up, you should end up with a folder structure like this:

App

Fill in the blanks on the MockNavigationRepository

Go back to the navigation/data/repositories/mock_navigation.repository.dart file and fill in the missing routes for each of the corresponding navigation items. Your updated MockNavigationRepository class should look like below:

class MockNavigationRepository implements INavigationRepository {
    
  @override
  List<LeftNavigationItem> getDefaultNavItems() {
    return [
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.user,
        label: 'My Profile',
        route: WelcomePage.route,
        isSelected: true
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.twitter,
        label: 'Twitter',
        route: TwitterPage.route,
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.linkedin,
        label: 'LinkedIn',
        route: LinkedInPage.route,
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.web,
        label: 'Web',
        route: WebPage.route,
        isSelected: false
      ),
      LeftNavigationItem(
        icon: PersonalPortfolioIcons.github,
        label: 'Github',
        route: GithubPage.route,
        isSelected: false
      ),
    ];
  }
}

Fill in the blanks on the AppRoutes

You are not done yet! You must go back to the app_routes.dart file and add the remaining routes for the placeholder pages we created. Under the ShellRoute

Your updated ShellRoute with the child routes in place for each page should look as follows:

//... app_routes.dart
//... rest of the code omitted for brevity

ShellRoute(
    navigatorKey: Utils.tabNav,
    builder: (context, state, child) {
        return PortfolioMainPage(child: child);
    },
    routes: [
        GoRoute(
            parentNavigatorKey: Utils.tabNav,
            path: WelcomePage.route,
            pageBuilder: (context, state) {
                return const NoTransitionPage(
                    child: WelcomePage()
                );
            }
        ),
        GoRoute(
            parentNavigatorKey: Utils.tabNav,
            path: TwitterPage.route,
            pageBuilder: (context, state) {
                return const NoTransitionPage(
                    child: TwitterPage()
                );
            }
        ),
        GoRoute(
            parentNavigatorKey: Utils.tabNav,
            path: LinkedInPage.route,
            pageBuilder: (context, state) {
                return const NoTransitionPage(
                    child: LinkedInPage()
                );
            }
        ),
        GoRoute(
            parentNavigatorKey: Utils.tabNav,
            path: GithubPage.route,
            pageBuilder: (context, state) {
                return const NoTransitionPage(
                    child: GithubPage()
                );
            }
        ),
        GoRoute(
            parentNavigatorKey: Utils.tabNav,
            path: WebPage.route,
            pageBuilder: (context, state) {
                return const NoTransitionPage(
                    child: WebPage()
                );
            }
        ),
    ]
),

Take it for a spin and now you should see the content in the middle region of our shell changing to the content of the corresponding page being navigated. Hooray!!

App

Let's proceed in building out some shared aspects of the application - the background color from the corresponding visited page.

Instead of making the background color part of each page, we'll make it as a separate widget that listens to the route of the currently navigated page and changes its color accordingly. This will allow us to continue getting familiar with providers and building shared entities in our apps.

Go to the styles/colors.dart file and add the following map at the bottom of the file:

//... colors.dart
//... rest of the code omitted for brevity

static Map<String, LinearGradient> pageColor = {
    WelcomePage.route: const LinearGradient(
        colors: [
            PersonalPortfolioColors.welcomePrimary,
            PersonalPortfolioColors.welcomeSecondary
        ],
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter
    ),
    TwitterPage.route: const LinearGradient(
        colors: [
            PersonalPortfolioColors.twitterPrimary,
            PersonalPortfolioColors.twitterSecondary
        ],
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter
    ),
    LinkedInPage.route: const LinearGradient(
        colors: [
            PersonalPortfolioColors.linkedInPrimary,
            PersonalPortfolioColors.linkedInSecondary
        ],
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter
    ),
    WebPage.route: const LinearGradient(
        colors: [
            PersonalPortfolioColors.webPrimary,
            PersonalPortfolioColors.webSecondary
        ],
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter
    ),
    GithubPage.route: const LinearGradient(
        colors: [
            PersonalPortfolioColors.githubPrimary,
            PersonalPortfolioColors.githubSecondary
        ],
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter
    )
};

We have a map entry for each of the page routes available, so that when the user navigates to that page, the background changes according to the colors configured.

Let's create the first iteration of our shared components. Go ahead and create a folder called shared under the lib folder. Eventually we will contain entities that will be shared across pages within our web app, such as animations, providers, other widgets, etc.

Inside the shared folder, create a subfolder called providers, and in turn, create a file called shared_providers.dart. This will encapsulate providers that we could use throughout our app - sure, providers are "global" in nature, but for organization purposes, we can keep these here inside this file.

Open the shared_profiles.dart file and add the following provider (along with any required imports):

final pageColorProvider = StateProvider<LinearGradient>((ref) {
  return PersonalPortfolioColors.pageColor[WelcomePage.route]!;
});

This is a StateProvider - another type of provider available in Riverpod. This is a type of provider whose value can be modified from outside, and whichever widget is watching this value, will get notified to be rebuilt. Here we are providing a default value initially (the route for the welcome page) but the intention is that anyone with access to reading this provider can set its state.

Let's see how we both consume it and trigger it to broadcast its changes.

In the shared folder, let's create a widgets subfolder - this will be the place where we'll put widgets that will be shared throughout the app.

Inside the widgets subfolder, create a file called pagecolor.dart and add the following code inside (with the corresponding imports):

class PageColor extends ConsumerWidget {
  const PageColor({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    var pageGradient = ref.watch(pageColorProvider);

    return Container(
      decoration: BoxDecoration(
        gradient: pageGradient
      )
    );
  }
}

We just created another ConsumerWidget that inside its build method watches for changes triggered by the pageColorProvider. Then in turn it pulls the information returned (the gradient mapped to the route) and displays it as a gradient inside a Container widget. Simple as that. Now, the way we want to trigger it is upon tapping on a navigation item - that's where we can capture the route before performing the navigation so we can display the corresponding background color.

Go to the features/navigation/presentation/viewmodels/leftnavigation.viewmodel.dart file and right above the update to the state, add the following line:

//... leftnavigation.viewmodel.dart
//... rest of the code omitted for brevity

ref.read(pageColorProvider.notifier).state = PersonalPortfolioColors.pageColor[item.route]!;

Based on the incoming selected navigation item, we extract the route, pull the corresponding gradient information and reset the state of our pageColorProvider using the ref.read(pageColorProvider.notifier).state syntax to obtain the state and assign a new one, thus triggering a change on whoever is listening (the PageColor widget in this case).

With all of this in place, let's consume our PageColor widget by adding it inside the shell page's Stack all the way at the bottom, so the background shows behind the pages, so go to the features/shell/presentation/pages/portfoliomain.page.dart and add the following code:

//... portfoliomain.page.dart
//... rest of the code omitted for brevity

Stack(
    children: [
        const PageColor(), // <-- add the PageColor here, at the bottom of the Stack
        Center(
            child: child
        ),
        const Align(
            alignment: Alignment.centerLeft,
            child: LeftNavigation(),
        ),
    ],
)

Run the app again (or do a hot restart) and make sure to start from the root url (/) and you should end up with a colorful sequence of page selections!

App

Let's continue building up on the UI structure of the pages. Hit Next to proceed.

We'll build the UI for the welcome feature based on the following schematics:

App

We'll also build the GreetingsLabel widget which cycles through multiple greetings in different languages. We'll deal with this a few steps below:

App

Go to the features/welcome/presentation/pages/welcome.page.dart and replace the existing content of the build method by this structure; we'll first build it with hard-coded values at first:

//... welcome.page.dart
//... inside the build method:

@override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Row(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Container(
                  width: 100,
                  height: 100,
                  decoration: BoxDecoration(
                    border: Border.all(color: PersonalPortfolioColors.welcomePrimary, width: 8),
                    shape: BoxShape.circle,
                    image: const DecorationImage(
                    image: NetworkImage('https://avatars.githubusercontent.com/u/5081804?v=4'),
                    fit: BoxFit.cover)
                  )
                ),
                const SizedBox(width: 40),
                const Icon(PersonalPortfolioIcons.wave,
                  size: 90, color: PersonalPortfolioColors.welcomeIcon
                )
            ]
          ),
          const Text("Hello", style: TextStyle(
              fontSize: 100,
              fontWeight: FontWeight.bold,
              color: Colors.white
            ),
          ),
          const Text.rich(
            TextSpan(
            style: TextStyle(fontSize: 100, color: Colors.white),
            children: [
                TextSpan(text: "I'm "),
                TextSpan(
                    text: 'Roman', 
                    style: TextStyle(fontWeight: FontWeight.bold)
                ),
              ]
            ),
            textAlign: TextAlign.center,
          ),
          Row(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(
                  PersonalPortfolioIcons.badge,
                  color: PersonalPortfolioColors.welcomePrimary,
                  size: 80
              ),
              const SizedBox(width: 20),
              Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.center,
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                    Text('Flutter GDE', textAlign: TextAlign.center, style: TextStyle(fontSize: 40, color: Colors.white)),
                    Text('Certified Cloud Architect', textAlign: TextAlign.center, style: TextStyle(fontSize: 40, color: Colors.white)),
                ],
              )
            ]
          )
        ]
      )
    );
}

Make sure that your updated WelcomePage looks like this:

App

Now let's proceed and make this dynamic, with its data fed from a provider and a corresponding repository, which we'll later move to Firebase and fetch it asynchronously.

Create the file and folder structure for the Welcome feature

Inside the welcome feature, create a subfolder called data with the following folder structure underneath it:

Once the file and folder structure is created, navigate to the welcome/data/models/welcome_page.model.dart and add the following code - a class named WelcomePageModel that represents the data model that will hydrate the welcome page UI:

class WelcomePageModel {
  final String name;
  final String title;
  final String subTitle;
  final String imgPath;
  final List<String> greetings;

  WelcomePageModel({
    required this.name,
    required this.title,
    required this.subTitle,
    required this.imgPath,
    required this.greetings
  });
}

Establish the contract for the welcome repository

As before, go to the welcome/data/repositories/iwelcome.repository.dart and paste the following code (with the required imports):

abstract class IWelcomeRepository {

  Future<WelcomePageModel> getWelcomePageData();
}

With this, we've establish the interface / contract that implementing repositories must follow to fetch the welcome datamodel through a Future.

Define now the mocked implementation of the repository that will retrieve the data - for now we'll mock it up; later we'll retrieve it from Firebase.

Go to the welcome/data/repositories/mockwelcome.repository.dart and paste the following code - add the required imports:

class MockWelcomeRepository implements IWelcomeRepository {

  @override
  Future<WelcomePageModel> getWelcomePageData() {
    
    return Future.delayed(const Duration(seconds: 2), () {
      return WelcomePageModel(
        name: 'Roman',
        title: 'Flutter GDE', 
        subTitle: 'Certified Cloud Architect',
        imgPath: 'https://avatars.githubusercontent.com/u/5081804?v=4',
        greetings: [
          "hello","hola", "bonjour", "ciao"
        ]
      );
    });
  }
}

Notice in the MockWelcomeRepository how we are simulating a delay in retrieving the data (using Future.delayed), as in a real-life scenario where there may be a slight delay in the data to arrive from an external source.

We need a viewmodel to capture the retrieval of the welcome data; later when we intercept the data, we need to feed the greetings to another provider which will handle the triggering of the greetings cycle. We'll do that later down in this step.

Go to the welcome/presentation/viewmodels/welcome.viewmodel.dart and add the following code (with required imports as usual):

class WelcomePageViewModel {

  final Ref ref;
  final IWelcomeRepository welcomeRepository;

  WelcomePageViewModel(this.welcomeRepository, this.ref);

  Future<WelcomePageModel> getWelcomePageData() async {
    var welcomePageData = await welcomeRepository.getWelcomePageData();
    return welcomePageData;
  }
}

Notice how we are injecting an instance of IWelcomeRepository and a Ref ref so we can read another provider from inside this viewmodel, as well as consume the incoming welcome data via the repository.

With all the data models and repositories in place, let's create the providers that will encapsulate the data and feed it to the UI.

Create the welcome feature providers (pt.1)

Go to the welcome feature, and locate the welcome/presentation/providers/welcome_page.providers and let's start adding our providers.

Start by creating a provider that encapsulates an instance of IWelcomeRepository - for now we'll use a mocked version of our repository, so use a Provider provider, as such:

//... welcome_page.providers.dart

final welcomeRepositoryProvider = Provider<IWelcomeRepository>((ref) {
  return MockWelcomeRepository();
});

We are adding our first Provider called welcomeRepositoryProvider which wraps the mocked implementation of our welcome feature repository. This brings synthetic data for testing purposes.

Next, create another Provider that wraps an instance of the WelcomeViewModel called welcomeViewModelProvider, as such:

// add this in the same welcome_page.providers.dart

final welcomeViewModelProvider = Provider<WelcomePageViewModel>((ref) {
  var repository = ref.read(welcomeRepositoryProvider);
  return WelcomePageViewModel(repository, ref);
});

Lastly, create a provider - a new type of provider called FutureProvider that invokes a wrapper method getWelcomePageData() method which internally calls the repository and triggers other actions.

// add this in the same welcome_page.providers.dart

final welcomeProvider = FutureProvider((ref) {
  final welcomeVM = ref.read(welcomeViewModelProvider);
  return welcomeVM.getWelcomePageData();
});

The FutureProvider is the equivalent of Provider but for asynchronous code.

FutureProvider is typically used for performing and caching asynchronous operations (such as network requests), nicely handling error/loading states of asynchronous operations and combining multiple asynchronous values into another value. We'll show you how to consume in a minute.

Consume the welcome provider in the WelcomePage UI

Let's proceed and consume the mocked data in our UI instead of having it hard-coded. We'll do some refactoring as before and turn the existing WelcomePage StatelessWidget into a ConsumerWidget so we can leverage the WidgetRef object ref being fed into the build method by Riverpod.

Inside the build method, we'll use the ref.read() method to read the welcomeProvider which returns an AsyncValue that envelopes the WelcomePageData data model. This AsyncValue has a nice way of handling error / loading / data states from this asynchronous operation.

Out of the loading state, it is common to display a loading indicator via a CircularProgressIndicator.

Out of the error state, we can display another widget detailing an error. Later we'll refactor this piece to make it look nicer. In its callback, you get an error object and a stack trace.

Out of the data state (the success case), we collect the incoming unwrapped data and consume it accordingly. Since this is a WelcomePageModel data model, we will consume the corresponding properties and feed it to the appropriate widgets.

The updated WelcomePage widget should look like this after implementing the refactoring needed (with required imports):

// updated welcome.page.dart

class WelcomePage extends ConsumerWidget {

  static const String route = "/welcome";
  const WelcomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {

    var welcomeDataAsync = ref.watch(welcomeProvider);

    return welcomeDataAsync.when(
      loading: () => const Center(child: CircularProgressIndicator(
        valueColor: AlwaysStoppedAnimation(Colors.white),
      )),
      error:(error, stackTrace) => const Text('error'),
      data: (welcomeData) {
        return Center(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Row(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Container(
                      width: 100,
                      height: 100,
                      decoration: BoxDecoration(
                        border: Border.all(color: PersonalPortfolioColors.welcomePrimary, width: 8),
                        shape: BoxShape.circle,
                        image: DecorationImage(
                        image: NetworkImage(welcomeData.imgPath),
                        fit: BoxFit.cover)
                      )
                    ),
                    const SizedBox(width: 40),
                    const Icon(PersonalPortfolioIcons.wave,
                      size: 90, color: PersonalPortfolioColors.welcomeIcon
                    )
                ]
              ),
              Text(welcomeData.greetings[0], style: const TextStyle(
                  fontSize: 100,
                  fontWeight: FontWeight.bold,
                  color: Colors.white
                ),
              ),
              Text.rich(
                TextSpan(
                style: const TextStyle(fontSize: 100, color: Colors.white),
                children: [
                    const TextSpan(text: "I'm "),
                    TextSpan(
                        text: welcomeData.name, 
                        style: const TextStyle(fontWeight: FontWeight.bold)
                    ),
                  ]
                ),
                textAlign: TextAlign.center,
              ),
              Row(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(
                      PersonalPortfolioIcons.badge,
                      color: PersonalPortfolioColors.welcomePrimary,
                      size: 80
                  ),
                  const SizedBox(width: 20),
                  Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.center,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                        Text(welcomeData.title, textAlign: TextAlign.center, style: TextStyle(fontSize: 40, color: Colors.white)),
                        Text(welcomeData.subTitle, textAlign: TextAlign.center, style: TextStyle(fontSize: 40, color: Colors.white)),
                    ],
                  )
                ]
              )
            ]
          )
        );
      }
    );
  }
}

If you take it for a spin, you'll notice a 2-second delay and showing the circular progress indicator before showing the mocked data populated in the UI:

App

Notice how we're pulling the data from the welcomeData object that arrives through the data state. For the greetings label, we're showing just the first one (welcomeData.greetings[0]); what we want is cycle through the list of greetings. For this, we'll extract that piece into a separate widget called GreetingsLabel with its separate viewmodel, provider - all the bells and whistles.

Create the GreetingsLabel viewmodels and providers

For the effect of the greeting labels cycling through every second and starting again, we'll need a Timer instance to execute in a periodic fashion.

Let's start with the provider.

Go to the existing welcome/presentation/providers/welcome_page.providers.dart and add a provider for the list of strings we'll feed into its viewmodel, as such:

//... add to the existing welcome_pge.providers.dart

final greetingsRawListProvider = StateProvider<List<String>>((ref) {
  return [];
});

Making it as a StateProvider will notify any other entity listening (i.e. any other provider) thus acting accordingly. We'll need another provider that wraps a viewmodel that will hold the logic for maintaining the timer, setting the current greeting being displayed, etc.

Go to the welcome/presentation/viewmodels/greetings_label.viewmodel.dart and add the following code (with its required imports):

class GreetingsViewModel extends StateNotifier<String> {
  
  final List<String> greetings;
  int greetingsCounter = 0;
  Timer greetingsTimer = Timer(Duration.zero, () {});
  
  GreetingsViewModel(super.state, this.greetings);

  void initializeGreetings() {

    greetingsTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (greetingsCounter == greetings.length) {
        greetingsCounter = 0;
      }

      state = greetings[greetingsCounter];
      greetingsCounter++;
    });
  }

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

  void resetTimer() {
    greetingsTimer.cancel();
    greetingsTimer = Timer(Duration.zero, () {});
  }
}

Let's dissect the code for a bit. The GreetingsViewModel class takes in a default state (a String) which will hold the greeting label currently being displayed as well as the actual list of strings to cycle through.

In the initializeGreetings() method is where the action happens - we create a periodic Timer for one second, increment a counter based on the amount of greetings, and reset the count back to 0. In each iteration, we reset the internal state with the greeting label corresponding to the current iteration count.

The dispose() and resetTimer() appropriately clean up resources and reset the timer respectively.

Let's go ahead now and create a corresponding provider that will encapsulate an instance of our newly created viewmodel. Go back to the welcome/presentation/providers/welcome_page.providers.dart and add this provider:

// add it to the existing welcome_page.providers.dart file

final greetingsViewModelProvider = StateNotifierProvider<GreetingsViewModel, String>((ref) {
  var greetings = ref.watch(greetingsRawListProvider);
  return GreetingsViewModel(greetings.first, greetings);
});

Notice how from inside the greetingsViewModelProvider we watch the greetingsRawListProvider, and upon being notified of an updated list, we instantiate a GreetingsViewModel, passing the first item in the list by default, plus the list of greetings.

With all the pieces in place, now go to the welcome/presentation/widgets/greetings_label.dart and paste the following code (add the required imports):

class GreetingsLabel extends ConsumerStatefulWidget {
  const GreetingsLabel({super.key});
  @override
  GreetingsLabelState createState() => GreetingsLabelState();
}

class GreetingsLabelState extends ConsumerState<GreetingsLabel> {

  late GreetingsViewModel vm;
  
  @override
  void initState() {
    super.initState();
    vm = ref.read(greetingsViewModelProvider.notifier);
    vm.initializeGreetings();
  }

  @override
  void dispose() {
    vm.resetTimer();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {

    var greeting = ref.watch(greetingsViewModelProvider);

    return Text(greeting, style: 
      const TextStyle(
        fontSize: 100,
        fontWeight: FontWeight.bold,
        color: Colors.white
      )
    );
  }
}

Notice the usage of the ConsumerStatefulWidget - another widget provided by Riverpod. We use it to leverage both the initState (to initialize the greetings) and the dispose() method (to reset the timer in the viewmodel, but not dispose it, since we may need it again if this widget gets reinstated).

We need to feed the incoming list of greetings coming from the WelcomePageModel we capture in the WelcomePageViewModel, so let's go to the welcome/presentation/viewmodels/welcome.viewmodel.dart file, and in the WelcomePageViewModel's getWelcomePageData() method, before returning the welcomePageData data model, add the following line:

//... inside the welcome.viewmodel.dart
//... in the getWelcomePageData() method,
//... before returning the welcomeData...

ref.read(greetingsRawListProvider.notifier).state = welcomePageData.greetings;

// return welcomeData

This will feed the greetingsRawListProvider provider with the incoming list of greeting labels (for now a mocked version) which will trigger the sequence of events that will make its way into the GreetingsViewModel and rebuild the UI accordingly.

Back on the welcome.page.dart, replace the placeholder Text widget pulling the first item in the welcomeData.greetings by the newly created widget GreetingsLabel.

// inside the data state:

/* data: (welcomeData) {
         return Center(
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.center,
             mainAxisAlignment: MainAxisAlignment.center,
             children: [
                Row(...),
*/              
                // replace the Text by the GreetingsLabel widget
                const GreetingsLabel(),

/*              Text.rich(...),
                Row(...)
              ]
           )
         );
       }
*/

Then, proceed to run the app again.

App

And voilá! We've got cycling greeting messages! Yay!! Amazing execution!

In the WelcomePage page widget, we were able to handle the three conditions available in the AsyncValue returned by the FutureProvider - the loading, error and data. The loading is covered by returning just a CircularProgressIndicator widget, the data - well, that's the happy path where we display the actual content we want to display upon receiving the data, but for the error state we just have a simple Text with the string "error".

Let's improve that by creating shared widget called ErrorNotification which will handle the displaying of an error message along with a more proper icon.

Let's go to the lib/shared/widgets and add a file called error_notification.dart and add the following class to it (with required imports):

class ErrorNotification extends StatelessWidget {

  final String message;
  const ErrorNotification({
    super.key,
    required this.message  
  });

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.warning, color: Colors.white, size: 80),
          const SizedBox(height: 20),
          ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 300),
            child: Text(
              message, textAlign: TextAlign.center, 
              style: const TextStyle(color: Colors.white, fontSize: 20)
            )
          )
        ],
      ),
    );
  }
}

This widget takes a String as a parameter, and displays it with a warning icon on top inside a ConstrainedBox with a 300px maximum width.

Let's take it for a spin.

Go to the WelcomePage widget (features/welcome/presentation/pages/welcome.page.dart) and replace the existing const Text(‘error') by our newly created ErrorNotification, feeding into it the error generated and passed through via the callback, as such:

// inside the welcome.page.dart...

/* 
return welcomeDataAsync.when(
    loading: () => const Center(child: CircularProgressIndicator(
        valueColor: AlwaysStoppedAnimation(Colors.white),
    )),
*/
    // replace the const Text('error') by the following line:
    error:(error, stackTrace) => 
        ErrorNotification(message: error.toString()),

/*
    data: (welcomeData) {
        //... rest of the code
    }
);
*/

Now, simulate an error condition occuring in your repository. Go to the MockWelcomeRepository class (welcome/data/repositories/mockwelcome.repository.dart file), then inside the Future.delayed() method, comment everything out, and add this line:

//... inside the mockwelcome.repository.dart

//... rest of the code omitted for brevity

return Future.delayed(const Duration(seconds: 2), () {
    return Future.error('Error retrieving the welcome page data');

    /*return WelcomePageModel(
        name: 'Roman',
        title: 'Flutter GDE', 
        subTitle: 'Certified Cloud Architect',
        imgPath: 'https://avatars.githubusercontent.com/u/5081804?v=4',
        greetings: [
            "hello","hola", "bonjour", "ciao"
        ]
    );*/
});

This will simulate an error being returned from your future as the data is fetched.

Run the application and take a look at how it looks:

App

NOTE: make sure to revert the changes before proceeding!

Hey, life is not always peaches and cream - you may get error conditions during the execution of your web app, for example, where you may navigate to the wrong page by mistake in order to gracefuly handle issues, kind of like we did with the ErrorNotification widget - but that's just one case.

This is what happens when you try to navigate to a page that doesn't exist:

App

Thanks to the GoRouter package we can handle this using an existing construct in the routes, using the errorBuilder and errorPageBuilder methods available in GoRouter, as such:

//... do not copy, just an example

GoRouter(
  /* ... */
  errorBuilder: (context, state) => const ErrorPage(errorMessage: state.error),

  // ... or... 
  errorPageBuilder: return AppRoutes.pageTransition(
    key: state.pageKey,
    page: const ErrorPage(errorMessage: state.error)
  )
)

By default, GoRouter comes with default error screens for both MaterialApp and CupertinoApp as well as a default error screen in the case that none is used.

This way you can tap into the routing mechanism of GoRouter, and whenever it doesn't find a matching route among the existing ones, it will default to this ErrorPage widget. Mind you, this widget does not exist yet, so let's go ahead and create it.

Under the features folder, create a feature folder called error, and corresponding presentation/pages subfolders as well. Inside the pages subfolder, create a file called error_page.dart and add the following code:

import 'package:flutter/material.dart';
import 'package:roman_web_portfolio/styles/colors.dart';

class ErrorPage extends StatelessWidget {

  final String errorMessage;
  const ErrorPage({
    super.key,
    required this.errorMessage  
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: const BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              PersonalPortfolioColors.errorBgTop,
              PersonalPortfolioColors.errorBgBottom
            ]
          )
        ),
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisSize: MainAxisSize.min,
            children: [
              const Icon(Icons.warning,
                size: 80,
                color: PersonalPortfolioColors.errorIcon
              ),
              const Text('Error!',
                textAlign: TextAlign.center,
                style: TextStyle(
                  fontSize: 100,
                  fontWeight: FontWeight.bold,
                  color: Colors.white
                )
              ),
              SizedBox(
                width: MediaQuery.of(context).size.width / 2,
                child: Text(errorMessage,
                  textAlign: TextAlign.center,
                  style: const TextStyle(
                    fontSize: 30,
                    color: Colors.white
                  )
                ),
              )
            ]
          )
        ),
      )
    );
  }
}

We created a StatelessWidget widget called ErrorPage that takes a String called errorMessage through its constructor, and we style it accordingly.

Back in the app_routes.dart, we use the errorBuilder method and hook up our newly created page, feeding into it the state.error as a string, as such:

//... inside the app_routes.dart
//... rest of the code omitted

errorBuilder: (context, state) {
  return ErrorPage(errorMessage: state.error.toString());
},

With this in place, try again to navigate to a page route that doesn't exist (i.e. welcome3). The routing system, since it does not find it, defaults to the errorBuilder callback, which returns the ErrorPage page widget, feeds the error from the state into this page, and displays it within a more customized error visualization.

App

And that wraps up this codelab - hope the topics covered have been useful and you can apply it to your Flutter web apps as well! Cheers!

In this codelab, we accomplished the following:

Please don't forget to follow me on social media:

Are you up for a challenge? Based on the knowledge acquired throughout this codelab, you should be able to tackle code challenges - feel free to go back and review the concepts introduced and refresh your memory on those. Remember, practice makes perfection!

Challenge #1: Build the Twitter Feature

Challenge #1a: Build the UI

Challenge #1 consists of developing further the Twitter feature page, as in the schematics below:

App

You should build this UI pretty similar to the WelcomePage widget, so take cues from there.

Challenge #1b: Build the TwitterPageModel data model

Create a class called TwitterPageModel with the following properties:

Suggestions: add the url_launcher package to handle the launching of the twitter URL. You can make the widget that wraps the handle property clickable by wrapping it inside a GestureDetector and handling the onTap event.

Make sure to place this class following the feature structure as in the welcome feature.

Challenge #1c: Create the repository that feeds the TwitterPageModel

Create an ITwitterRepository interface that defines a contract with a method called getTwitterData() which should return an instance of TwitterPageModel.

Create a corresponding TwitterRepository and a MockTwitterRepository; both should implement the ITwitterRepository and the mock returns a mocked version of your TwitterPageModel for use within this app.

Create a viewmodel called TwitterViewModel that receives as a parameter an instance of ITwitterRepository, exposes a wrapper method called getTwitterData and calls the repository's getTwitterData, returning a Future wrapping the data model.

Challenge #1d: Create the corresponding Twitter providers

Using Riverpod, create the corresponding providers that:

Challenge #1c: Watch a reference to your twitter provider

In your TwitterPage widget created as a widget that receives a reference to the WidgetRef, watch your twitter provider, and in the data state, read the TwitterPageModel data and consume the corresponding properties, feeding them into the appropriate widgets accordingly.

Challenge #2: Build out the LinkedPage

Follow the same pattern as in the TwitterPage challenge to complete this. The UI should look as follows:

App

Challenge #3: Build out the Github Page

Follow the same pattern as in the TwitterPage challenge to complete this. The UI should look as follows:

App

Challenge #4: Build out the Web Page

Follow the same pattern as in the TwitterPage challenge to complete this. The UI should look as follows:

App