Welcome to the exciting world of Flutter Web!

Flutter is an open-source UI Toolkit supported by Google for building beautiful, natively-compiled user interfaces in a multi-platform fashion, using Dart, another open-source programming language supported by Google, as its language.

Flutter for Web provides the lowest barrier to entry when it comes to becoming a profilic Flutter developer. Most of the concepts learned in Flutter can be applied in a multi-platform scenario, and web is not an exception, so join us in building compelling web apps by going through the codelabs in this series.

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 a codelab series focused on Flutter Web, in which you'll learn the following:

The following screenshots depict what we'll be building throughout this series:

Are you ready??? Hit Next to proceed.

App

Design Mockup

Design Mockup

Design Mockup

Design Mockup

Design Mockup

Design Mockup

Design Mockup

Knowlege requirements

Tool / Software requirements

Optional: DartPad

For learning throughou this series, you could follow along using DartPad for web if you choose so. But for best developer experience and for deploying to Firebase you will need to have VS Code at a minimum.

IF USING DARTPAD, SKIP THE FOLLOWING STEP

Once Flutter is installed on your machine, let's create your first Flutter project. Navigate to the folder in which this project will live.

Once inside the folder, from a terminal or from VS Code's terminal, execute the command flutter create PROJECT_NAME, as such:

flutter create PROJECT_NAME

This will instruct the framework to generate a startup Flutter project with all the minimum requirements to start up.

You will have a folder named PROJECT_NAME or whatever name you pick (in my case I'll call it roman_web_portfolio); make sure to navigate inside this folder; the rest of the steps will depend on you being at the root of your project. You can do cd PROJECT_NAME or re-open VSCode with this folder as the root of your environment.

From the generated files, navigate to the main.dart file. There's a bunch of boilerplate code; for now let's just test that things are running fine.

Go ahead and execute the command flutter run -d chrome at the root of your project. This will instruct Flutter to run your project, using Chrome browser as your target device (hence the -d flag):

flutter run -d chrome

Make sure you can see the default counter sample app:

App

Looking good! Click on the floating button and check that the counter in the middle of the screen goes up, just as a sanity check. If so, your project is set up, Flutter is running well - ready for the next step! Hit Next when ready.

Good organization on your project goes a long way, so let's start by setting the foundation of this project, starting by a folder structure that makes sense for this web app. I'll follow this convention although this is a small web app, but can grow later:

Confirm that your project structure looks like this and proceed to the next step.

App

Let's start building some confidence before we get deeper. Let's create our first page, which will be a splash screen, where we'll simulate a delay before proceeding further into the app.

Inside the pages folder, add a new file called splash_page.dart. Add the following contents:

import 'package:flutter/material.dart';

class SplashPage extends StatelessWidget {
  const SplashPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

Replace the returned Container with a Scaffold widget, containing a Text widget wrapped inside a Center widget. Add any text you want:

// ... inside the build method:

return const Scaffold(
    body: Center(
    child: Text('Hello Flutter!')
    )
);

Save that. Go back to the main.dart file; delete all content inside of it. We'll start from scratch building our app.

Add the required import of the Material library, as wel as the customary main method.

Inside it, call the runApp method, passing it a newly created widget called PortfolioApp as the root of our application (doesn't exist yet - we'll create it in an moment):

import 'package:flutter/material.dart';


void main() {
  runApp(const PortfolioApp());
}

Right under this code, create the corresponding class to create a custom widget that inherits StatelessWidget called PortfolioApp. This custom widget will override its build method as always, and wrap the main MaterialApp widget - the foundation of our material-styled app, as such:

class PortfolioApp extends StatelessWidget {
  const PortfolioApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Portfolio App',
      debugShowCheckedModeBanner: false,
      home: SplashPage()
    );
  }
}

Notice we're introducing our SplashPage widget created earlier; import it now so the app doesn't start barking.

// at the top of the file, under the material import
// add the corresponding import pertaining to your project 
import 'package:flutter/material.dart';
import 'package:roman_web_portfolio/pages/splash_page.dart';

So to recap, we created a SplashPage custom widget as a page, and we've assigned it as the home property of our MaterialApp widget.

Let's run what we have by calling flutter run -d chrome or if you still left the app running from before, just do a hot reload (hit ‘R' on the command line, or Shift-R - both work the same on the web).

App

Doesn't look like much, but we've created our first page - we are on a roll!

With that in place, let's finish bringing some other complementary pieces to our app, such as the fonts and color definitions we'll use throughout.

We'll be using our own custom font throughout the app as opposed to the ones that come out of the box, as well as install our own icon font - a way to display vector-based images from a font file.

Download the font files (both Product Sans and the icon fonts) available on this link.

Unzip the package and drag all the fonts (all .ttf files available: Product Sans Bold.ttf, Product Sans Italic.ttf, Product Sans Regular.ttf and PersonalPortfolioIcons.ttf) and add them inside the assets/fonts folder we created earlier.

Your structure should look like this:

App

Configure the fonts on the app

Navigate to the pubspec.yaml and paste the following config section at the root of the indentation (also you can find a commented section that can guide you on that) - make sure the indentations are correct!:

fonts:
    - family: Product Sans
      fonts:
        - asset: assets/fonts/Product Sans Regular.ttf
        - asset: assets/fonts/Product Sans Bold.ttf
          weight: 900
        - asset: assets/fonts/Product Sans Italic.ttf
          style: italic
    - family: PersonalPortfolioIcons
      fonts:
        - asset: assets/fonts/PersonalPortfolioIcons.ttf

Notice how we defined the font family as Product Sans - we'll use that name in a minute.

Apply the font family to the app

With the fonts imported, let's apply them to the app. Navigate to the main.dart, and inside our MaterialApp widget, set its theme property to a ThemeData instance, with its fontFamily property set to the name we defined our font to be ("Product Sans"). The new updated MaterialApp widget will look like this:

//.. rest of the code

return MaterialApp(
    title: 'Portfolio App',
    theme: ThemeData(
        fontFamily: 'Product Sans',
    ),
    debugShowCheckedModeBanner: false,
    home: const SplashPage()
);

NOTE: Rebuild the app by shutting it down and restarting it again so it picks up the newly added files.

Run the app again (flutter run -d chrome) and check the fonts got applied:

App

Apply the Icon Fonts

Great. Now let's apply the icon fonts; half of the work is done since we imported the icon font file in the assets/fonts folder in the previous step (the file called PersonalPortfolioIcons.ttf). We build it using a tool called FlutterIcon available at this link.

The cool thing about FlutterIcon is that it generates a .dart file already setup with corresponding Dart properties mapped to each of the icon fonts on your file.

In the package we downloaded earlier, aside from the .ttf files, there is another important file:

Drag the personal_portfolio_icons_icons.dart file inside the lib/helpers folder of the project.

Your structure then should look like this:

App

Let's test that the icon fonts are functional. In the SplashPage widget, replace the Text widget inside the Center widget by an Icon widget, adding one of the existing icon mappings (try the PersonalPortfolioIcons.wave - the waving hand icon), as such: (keep in mind to import the icon mapping class in this file!)

// ... inside the SplashPage class, replace the line that says:
// Text('Hello, Flutter!') by the following line:

Icon(PersonalPortfolioIcons.wave)

Your class will look like this after the change:

App

Rebuild the app and reload the browser, you should be able to see a waving hand icon, as such:

App

And we're all set to continue. Hit Next to proceed.

The navigation strategy for this app will be as follows:

App

The package we'll use is Go_Router supported by the Flutter team at Google, which is a declarative routing package for Flutter that uses the Router API to provide a convenient, url-based API for navigating between different screens. You can define URL patterns, navigate using a URL, handle deep links, and a number of other navigation-related scenarios.

Following that declarative aspect, we'll go ahead and create a file that will encapsulate the routes.

Start by creating a utilities class that will hold some unique keys that our navigation will require so we can refer to it programmamtically throughout the app.

Create a file called utils.dart in the lib/helpers folder. This class will contain three important Global keys. Global keys are used to uniquely identify elements in the widget tree, whcih provide you access to their state, therefore we'll use them to refer to the navigation and the main scaffold on this app.

utils.dart

import 'package:flutter/material.dart';

class Utils {

  static GlobalKey<NavigatorState> mainNav = GlobalKey();
  static GlobalKey<NavigatorState> tabNav = GlobalKey();
  static GlobalKey<ScaffoldState> mainScaffold = GlobalKey();
}

Import the go_router package into your app via the flutter pub add go_router, which fetches it from *pub.dev and installs it onto your app. Execute the following command:

flutter pub add go_router

Create a file called app_routes.dart inside the lib/routes folder, and add the initial configuration for our application navigation route strategy, as follows:

import 'package:go_router/go_router.dart';
import 'package:roman_web_portfolio/helpers/utils.dart';
import 'package:roman_web_portfolio/pages/splash_page.dart';

class AppRoutes {

  static final router = GoRouter(
    initialLocation: '/',
    navigatorKey: Utils.mainNav,
    routes: [
      GoRoute(
        parentNavigatorKey: Utils.mainNav,
        path: '/',
        builder: (context, state) => const SplashPage(),
      )
    ]
  );
}

Let's dissect this code.

We create a static final instance of GoRouter, and pass the following initialization arguments:

Every page that needs to be navigated to MUST have a GoRoute route instance inside the routes array for this to work.

Integrate the GoRouter navigation into our app.

Let's do some refactoring to integrate GoRouter to the app.

Back on the main.dart file, refactor the MaterialApp widget and replace the default constructor by the MaterialApp.router constructor. Remove the home parameter since we won't be hardcoding the home page, but be fed by the router configuration available in the AppRoutes.router instance created earlier, and add the following parameters to it. Make sure to import the AppRoutes class:

//... rest of the code

// This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Portfolio App',
      theme: ThemeData(
        fontFamily: 'Product Sans',
      ),
      debugShowCheckedModeBanner: false,
      routeInformationProvider: AppRoutes.router.routeInformationProvider,
      routeInformationParser: AppRoutes.router.routeInformationParser,
      routerDelegate: AppRoutes.router.routerDelegate,
    );
  }

//...

Notice the routeInformationProvider, routeInformationParser, and the routerDelegate are the hooks required to be added at the MaterialApp level to incorporate the navigation at the root level.

Test that the navigation integration was successful by just running the app again (flutter run -d chrome) and you should see the same splash page, but now being served by the go_router implementation. Nice!

App

Let's do some refactoring. You notice a few things hardcoded in the routes configuration, such as the name of the route, etc. Let's change that. Every page should be able to feed its own route name for better control and management.

Go to the SplashPage class and create a static const String field called route. Name it to what will correspond to the url path people will type to navigate to the corresponding page. We'll name this "/splash". You will do this for every single page class you create moving forward.

//... inside the SplashPage class

static const String route = '/splash';


Make it look like this:

App

Go back to the AppRoutes class, and in the GoRouter configuration, replace the initialLocation value by SplashPage.route, and the path property on the first route by SplashPage.route as well. It should look as follows:

App

If you test it again, there should be no change. And with that, routing is configured in your application. We'll come back to this file to add the rest of the routes, and we'll learn about nested navigations with ShellRoute later.

Hit Next to continue.

We created earlier the placeholder for the Splash page, but this time we'll fully develop it.

This is what we'll shoot for (see below). This will be an intermediate page with a fake delay simulating some processing being done, then it should proceed to the next page (the main section) of the app.

App

The following is the schematics of this app and how we'll tackle it in Flutter:

App

Add Color definitions for this app

Before we start, let's add some foundational elements we'll need for this app, such as color definitions.

Add Colors

Add a file called colors.dart inside the lib/helpers folder and a class with the name PersonalPortfolioColors, which will be a list of colors we'll use throughout our app. Feel free to change it to yours. Some of these will be the foundation of the theming we'll be implementing later. Grab the content from the snippet below:

import 'package:flutter/material.dart';

class PersonalPortfolioColors {

  // for the dark theme
  static const Color mainBlue = Color(0xFF51BFD7);
  static const Color secondaryBlue = Color(0XFF1E6B7C);
  static const Color lightBlue = Color(0xFF5DD1EB);
  static const Color textColor = Colors.white;
  static const Color primaryDark = Color(0xFF1F6D7E);
  static const Color lightLabel = Color(0xFFD5F4FB);

  // for the light theme
  static const Color lightPrimaryBlue = Color(0xFFECF7FA);
  static const Color lightSecondaryBlue = Color(0xFFC3E9F1);
  
}

Modify the SplashPage class

Let's go to the splash_page.dart file. Start by modifying the color to the parent Scaffold widget and adding a Row widget to the existing Center widget, with a MainAxisSize.min as its mainAxisSize. Remove the const from the Scaffold since its properties are not hardcoded anymore. Import the colors.dart from your own project as well:

///... body of the SplashPage

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: PersonalPortfolioColors.mainBlue,
      body: Center(
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [

            // ... REST OF THE CODE WILL GO HERE!

          ]
        )
      )
    );
  }

Inside the Row, let's place both the progress indicator and the label. Let's start with the progress and the icon. We'll use a Stack to overlap them, wrapped inside a SizedBox with fixed dimensions (60px) to constrain it:

//... inside the Row's children array:


SizedBox(
  width: 60,
  height: 60,
  child: Stack(
    children: const [
        Center(
            child: Icon(
                Icons.account_circle, 
                color: Colors.white, 
                size: 50
            )
        ),
        Center(
            child: SizedBox(
                width: 60,
                height: 60,
                child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation(Colors.white),
                ),
            ),
        )
    ],
  )
),

Notice that the Stack is stacking our two Center widgets wrapping our Icon (with the account_circle) and the CircularProgressIndicator.

Test it and check it out (you should be getting the spinning and the icon only for now):

App

Let's move right along by adding the label next to it with some spacing in between:

// copy this right under the existing SizedBox containing the image

const SizedBox(width: 20),
const Text('Loading an awesome,\nKick-ass Profile...',
    style: TextStyle(color: Colors.white)
),

Your code should look like this at this point:

App

Take it for a spin and your splash page layout should be complete:

App

Nice! Oh wait, that page is frozen in time - it doesn't go anywhere! Wait, wait - we'll get there. In the next section we'll tackle simulating a delay and navigating to the next page afterwards.

Simulating a delay / navigating to the main page.

Let's create the hooks for it. First, let's create a placeholder page for us to go to, which we'll develop further. Create a file called portfoliomain_page.dart and add a class called PortFolioMainPage with the following content:

import 'package:flutter/material.dart';
import 'package:roman_web_portfolio/helpers/utils.dart';

class PortfolioMainPage extends StatelessWidget {

  static const String route = '/main';

  const PortfolioMainPage({super.key});

  @override
  Widget build(BuildContext context) {
     return Scaffold(
      key: Utils.mainScaffold,
      body: Container(
        child: Center(
          child: Text('Main Page!')
        )
      )
     );
  }
}

Notice the route property with the value /main - this is what we'll use to hook a route to our routing / navigation infrastructure, and the splash will direct the user there after a delay. Let's go to the app_routes.dart file inside the routes folder to add this temporary page to navigate to after the splash.

THIS IS TEMPORARY AND WE WILL CHANGE IT LATER WITH THE NESTED NAVIGATION

//... in the app_routes.dart file, 
// inside the "routes" array, under the SplashPage route...

GoRoute(
    parentNavigatorKey: Utils.mainNav,
    path: PortfolioMainPage.route,
    builder: (context, state) => const PortfolioMainPage(),
)

Implement a delay before navigating away

With the route in place, let's go back to our SplashPage page code, and implement the delay.

Let's turn the SplashPage Stateless widget to a StatefulWidget since we need to take advantage of the initState lifecycle methods to do some initialization upfront, as well as the dispose method to do some clean-up.

// make it Stateful by creating the StatefulWidget and corresponding State class
// as usual...

class SplashPage extends StatefulWidget {

  static const String route = '/splash';
  const SplashPage({super.key});

  @override
  State<SplashPage> createState() => _SplashPageState();
}

class _SplashPageState extends State<SplashPage> {

    @override
    Widget build(BuildContext context) {
        /// rest of the code here
    }
}

THE REST OF THE CODE WILL FOCUS ON THE STATE PORTION OF THE SPLASHPAGE PAGE WIDGET

Inside the state _SplashPageState, create a property called delayTimer, type Timer and initialize it as such:

//... inside the _SplashPageState class

Timer delayTimer = Timer(Duration.zero, () {});

Override the class's initState method and initialize the delayTimer by creating an instance of Timer, with a Duration of 2 seconds, as such:

 @override 
void initState() {
    super.initState();
    delayTimer = Timer(const Duration(seconds: 2), () {
        // rest of the code will go inside this callback
    });
}

The Timer will simulate a 2 second delay, after which it will invoke the callback supplied.

Add the following code inside the callback:

//... inside the Timer's callback 

GoRouter.of(context).go(PortfolioPageMain.route);

What this will do is retrieve the GoRouter instance using the supplied BuildContext context, from which you can invoke one of its methods (in our case, .go()), which takes a string parameter - the name of the route to (in our case, the PortfolioPageMain page route /main).

Make sure to dispose of the Timer instance by overriding the dispose method:

//.. inside the class, usually at the bottom...

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

Take it for a spin and you should now see it come together: restart the app from the root ("/"), then you'll see the splash page briefly, followed by a navigation to the main page:

App

Awesome! Splash page is hooked up! Let's proceed now and build out the shell / skeleton of our nested navigation strategy.

After the splash screen, we want to be able to land on a page that in turn has other sub-pages, so we can toggle them on and off using some common navigation patterns like a navigation rail on the left, and pages swap each other out. How do we accomplish this in Flutter? With go_router and the concept of ShellRoutes.

A ShellRoute is a route that displays a UI shell around the matching child route.

When a ShellRoute is added to the list of routes on GoRouter or GoRoute, a new Navigator that is used to display any matching sub-routes, instead of placing them on the root Navigator.

App

Create Placeholder Pages

Let's first go and create the placeholder pages for each of the pages that will constitute our nested navigation: a page for our profile (WelcomePage), a page for our social media (TwitterPage, LinkedInPage), one for a potential web portfolio (WebPage) and one for our Github stuff (GithubPage).

CREATE ALL THE FOLLOWING PAGES UNDER THE PAGES FOLDER:

welcome_page.dart

import 'package:flutter/material.dart';

class WelcomePage extends StatelessWidget {

  static const String route = '/welcome';

  const WelcomePage({super.key});

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

twitter_page.dart

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!')
    );
  }
}

linkedin_page.dart

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!')
     );
  }
}


web_page.dart

import 'package:flutter/material.dart';

class WebPage extends StatelessWidget {

  static const String route = '/webpage';

  const WebPage({super.key});

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

github_page.dart

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!')
    );
  }
}

Your file structure should look like this:

App

Set up the GoRouter nested navigation configuration

With all the pages in place, let's now proceed to create the corresponding configuration for the nested navigation.

What we want is that when the user navigates to the main page after the splash screen, they remain there, while allowing to perform the navigation in place and swap them in and out of the main page. The main page will serve as a "shell" so to speak for the rest of the pages to render themselves. They will share the same navigation bar, and the shell just swaps them in and out. This is where the ShellRoute comes into play.

This is a schematic of how it works:

App

Let's build that!

Let's refactor the PortfolioMainPage which will serve as the "shell" container, through which the pages will be displayed.

Add a required constructor parameter type Widget called child. The GoRouter navigation framework, upon swapping views due to a navigation request, will inject the corresponding page widget in here.

//... update the constructor and add
// the 'child' property

final Widget child;
const PortfolioMainPage({
    super.key,
    required this.child  
});

Remove the Text widget inside the Center widget. Your newly updated PortfolioMainPage should look like this:

App

Now let's go to the app_routes.dart file, and set up the shell route and nested routes.

Replace the existing GoRoute entry for the PortfolioMainPage by an instance of the ShellRoute:

// add this under the existing SplashPageRoute by 
// replacing the existing GoRoute entry for the PortfolioMainPage
// by the following code:

ShellRoute(
    navigatorKey: Utils.tabNav,
    builder: ((context, state, child) {
       // pages get injected here
    }),
    routes: [
        // all nested routes go here!
    ]
)

The ShellRoute instance will hold all the related sibling routes under the routes array, which will get swapped out inside the PortfolioMainPage page widget, which serves as their shell. Notice how the ShellRoute has its own navigatorKey (tabNav) we use this when we want to reference the navigation state for all the pages under this nested group. You use the context reference supplied via this global key to point the navigation to this route group whenever someone wants to navigate to any of the pages in the nested routes under it. More on this later.

The key here is the builder callback, which routes the corresponding route's page widget via the child parameter, which in turn gets injected into the shell page (in our case, the PortfolioMainPage).

Go ahead and add this piece of code into the ShellRoute's builder method:

//... inside the builder method...

return PortfolioMainPage(child: child);

Add a GoRoute route instance under the routes array, for each of the corresponding child pages:

//... inside the routes array...

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: WebPage.route,
        pageBuilder: (context, state) {
            return const NoTransitionPage(
                child: WebPage()
            );
        }
    ),
    GoRoute(
        parentNavigatorKey: Utils.tabNav,
        path: GithubPage.route,
        pageBuilder: (context, state) {
            return const NoTransitionPage(
                child: GithubPage()
            );
        }
    )
]

Notice a slight change in the GoRoute instance this time around: instead of using the builder which builds the corresponding page widget and returns it (using the default transition out of the box from GoRouter which is a slide transition), we're using pageBuilder, which is used to customize the transition animation when that route becomes active. In our case we want to override it so as not to perform a slide transition but a "no-transition" animation, hence wrapping our page inside the NoTransition page, provided by go_router.

Let's go back to the SplashPage and replace the destination page after the delay.

// inside the initState, replace the destination page, 
// and point it to the WelcomePage

delayTimer = Timer(const Duration(seconds: 2), () {
    GoRouter.of(context).go(WelcomePage.route);
});

Testing the routing (manually for now) it should allow us to navigate to the corresponding pages. Your implementation should look as follows:

App

Since we don't want to be navigating these manually, let's create the navigation rail that will allow us to navigate to each one of these pages programmatically, as well as spruce up our shell page a bit, shall we?

We already have a placeholder page for our PortfolioMainPage, which is currently displaying each of the corresponding child nested pages.

But let's make it more like shell, with regions designated for the corresponding page elements (navigation, body, theme toggle, etc.).

App

Go to the PortfolioMainPage class and let's do some refactoring. Add a gradient to the Container widget in the body of the Scaffold via its decoration property:

//... inside the Container widget:

decoration: const BoxDecoration(
    gradient: LinearGradient(
    colors: [
        PersonalPortfolioColors.mainBlue,
        PersonalPortfolioColors.secondaryBlue
    ],
    begin: Alignment.topCenter,
    end: Alignment.bottomCenter
    )
),

The current child of the Container is a Center. Instead, wrap the Center inside a Stack - we will need it to overlay things on top of it (the theme button):

//... wrap the existing Container's child
// inside a Stack widget

child: Stack(
    children: [
        Center(child: widget.child),
    ]
)


Under the Center widget, still inside the Stack, add a new component (doesn't exist yet - we'll create it in a sec) called LeftNavigation. Create a file called left_navigation.dart inside the widgets folder.

Add the following code for the time being:

//.. inside the left_navigation.dart in the widgets folder,
// add the following code:

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text("Left nav")
    );
  }
}

With that in place, go back to the PortfolioMainPage, and right under the Center widget inside the Stack, add the newly created LeftNavigation widget wrapped inside an Align widget - do the proper imports as well:

//.. inside the Stack, under the current Center:

const Align(
    alignment: Alignment.centerLeft,
    child: LeftNavigation(),
),

Your code structure should look like this afterwards:

App

Take it for a spin and your page should look like this:

App

Let's develop the LeftNavigation widget a bit further in the next section. Hit Next when ready.

The navigation rail will allow us to tap on the corresponding icon that will navigate the user to the desired page.

App

This is the schematics of what we'll be tackling for the left navigation:

App

Let's do it.

Create the LeftNavigationItem model

In order to capture the essence of what a single navigation item is, let's create a PODO class called LeftNavigationItem inside the models folder, with the following properties:

left_navigation_item.dart

import 'package:flutter/material.dart';

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

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

Populate default items for the navigation

In the Utils class, create a method called getDefaultNavigationItems() which will return our navigation items already populated as required:

/// inside the Utils.dart class

static List<LeftNavigationItem> getDefaultNavItems() {
    return [
        LeftNavigationItem(
            icon: PersonalPortfolioIcons.user,
            label: 'My Profile',
            route: WelcomePage.route
        ),
        LeftNavigationItem(
            icon: PersonalPortfolioIcons.twitter,
            label: 'Twitter',
            route: TwitterPage.route
        ),
        LeftNavigationItem(
            icon: PersonalPortfolioIcons.linkedin,
            label: 'LinkedIn',
            route: LinkedInPage.route
        ),
        LeftNavigationItem(
            icon: PersonalPortfolioIcons.web,
            label: 'Web',
            route: WebPage.route
        ),
        LeftNavigationItem(
            icon: PersonalPortfolioIcons.github,
            label: 'Github',
            route: GithubPage.route
        ),
    ];
}

With that populated, let's use this to hydrate our LeftNavigation widget.

In the LeftNavigation widget class we created earlier, add the following decoration to the existing Container widget, to give it a gradient look in the background, as well as some 20px of padding all around it:

// ... inside the existing Container:

// 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: Text("Left Nav")

// )

Replace the existing placeholder Text widget by a Column:

//.. inside the Container, as the new child:

    child: Column(
        children: [],
    ),

///

Let's now populate the Column's children property by feeding into it the list of default items from the Utils class we created earlier. This is temporary; it's just to see the navigation come alive.

Use the generate factory method from the List class to generate a list of widgets. Inside its corresponding callback method (called once per list item), then return a Container widget (for margin), which in turn will wrap an IconButton widget, as such:

//... populate the 'children' property from the Column:

child: Column(
    children: List.generate(
        Utils.getDefaultNavItems().length, (index) {

        var navItem = Utils.getDefaultNavItems()[index];

        return Container(
            margin: const EdgeInsets.only(top: 20, bottom: 20),
            child: IconButton(
                iconSize: 30,
                icon: Icon(
                    navItem.icon,
                    color: Colors.white,
                ),
                onPressed: () {
                    // TODO: perform the navigation
                }
            ),
        );
                
        }
    ),
),

Notice how within the iteration, we pull the corresponding LeftNavigationItem instance using the supplied index, then populate the IconButton with the corresponding icon property.

In the IconButton's onPressed event, temporarily test whether the icons are responding to the click and whether they can trigger the navigation. This logic is for testing and shouldn't go in the widget logic, but in the business logic. We'll move it later. For now, let's test.

In the onPressed event, add the following:

//.. inside the onPressed event of the IconButton:

GoRouter.of(context).go(navItem.route);

This calls GoRouter accessible via the widget's context, passes the corresponding route value to it, and GoRouter interprets the route action and renders the corresponding page accordingly. Neat!

Running the project at this point should yield the expected results, as shown below:

App

You noticed how all navigation items looked white, not selected, right? Also we are hardcoding the items by populating the list of navigation items right inside the widget's build method. This is bad practice. The best way is to manage this business logic by using a state management strategy.

I don't want to make this codelab a state management codelab, but there's a few things to clarify about the topic.

State management in Flutter is just how we organize our app to most effectively access state and share it across widgets. State is data that feeds our UI and make it come alive, in our case, the state of the navigation items' selection is the state. Now we need to tell each navigation item to redraw itself based on the change in the state, but we cannot change this data within our widgets since our widgets are rebuilding and widgets are immutable, hence the need to "lift the state" out of the widget rebuilding logic into a separate class.

Let's use the provider package for it. Read more about state management here.

Import the provider package

Run the following command to install provider in this project:

flutter pub add provider

Inside the services folder, create a file called left_navigation_service.dart and inside it, a class called LeftNavigationService that extends ChangeNotifier - this is the premise of the Provider package: you create a class that notifies its listeners (in our case, our left navigation widget) when it's time to rebuild the widgets based on the change in state. In our case, the change in state will be flipping the isSelected property from true to false when the user taps on it.

Add a property called items, type List<LeftNavigationItem>, and feed into it the preexisting default navigation items. This will ensure that upon loading this class, it has already the items pre-populated in memory.

left_navigation_service.dart

//.. NOTE: do the proper imports!

class LeftNavigationService extends ChangeNotifier {

    List<LeftNavigationItem> navItems = Utils.getDefaultNavItems();

}

In the LeftNavigationService's constructor, set the first LeftNavigationItem from the list as selected by setting its isSelected property to true:

// add this constructor;
// set the default selected item (the first one):

LeftNavigationService() {
    navItems.first.isSelected = true;
}

Create a method called selectNavItem, which takes an instance of LeftNavigationItem - the currently item selected. This will be fed from the UI upon the user clicking on an item.

// add this method inside the service class

void selectNavItem(LeftNavigationItem item) {
    for (var element in navItems) {
      element.isSelected = item == element;
    }

    GoRouter.of(Utils.tabNav.currentContext!).go(item.route);
    notifyListeners();
}

Let's inspect the logic. Right upfront, we loop through all items, and only mark as selected the one that matches the incoming LeftNavigationItem instance - the rest we set them to false.

Notice where we've moved the logic to perform the GoRouter navigation from the widget to this service. We use the Utils.tabNav.currentContext to drive the navigation straight into the group of nested routes referenced by this navigatorKey. Then, we pass the corresponding item.route value to the .go() method, followed by a call to the notifyListeners so the listening UI (the LeftNavigation widget) knows that it needs to rebuild since there's been a change in its state, thus reflecting the changes in the UI accordingly.

Let's integrate this service to the app from the root so it is accessible to all descendant widgets.

Add a MultiProvider widget and inject the LeftNavigationService

At the root of the app, inside the runApp method, wrap the existing PortfolioApp widget inside a MultiProvider widget. Inside its providers property, add the newly created LeftNavigationService, wrapped inside a ChangeNotifierProvider which returns it in its create() method.

Replace the current main method with the following code:

// update the main method with the following:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(
          create: (_) => LeftNavigationService(),
        ),
      ],
      child: const PortfolioApp()
    )
  );
}

Make sure to do the proper imports. This minimum setup should be enough to inject this service and make it accessible to all descendant widgets, including our LeftNavigation widget, which we'll access in a minute. Now let's hook this up to the left navigation widget now.

Back in the LeftNavigationWidget class, wrap the existing Column widget housing our navigation items inside a Consumer widget, of type LeftNavigationService. What this will do is create a widget around our Column that will listen for every change the LeftNavigationService emits, thus causing this portion of the UI to rebuild. It does it once initially, and every time someone calls notifyListeners inside the service class.

Your code should look like this:

App

Inside the Consumer widget, since this code will trigger every time we call notifyListeners, let's then perform the logic to flip the color of the currently selected item from white to a color that goes with our theme.

Let's replace the instances of Utils.getDefaultNavItems by navService.navItems since the items now are coming from our service instead of the Utils class:

App

Go ahead and create a property called navItemColor which will hold the logic whether to change it to white or blue depending on whether the item is selected:

//.. add this under the 'navItem' property:

var navItemColor = navItem.isSelected ? 
                  Colors.white : PersonalPortfolioColors.lightBlue;

Once done, feed this property into the color property of the IconButton widget:

App

Lastly, replace the navigation trigger to GoRouter inside the IconButton's onPressed event, by the already provided selectNavItem method in the LeftNavigationService service, as such:

//... inside the 'onPressed' event:

navService.selectNavItem(navItem);

Remember what this method will do: it will supply the currently selected LeftNavigationItem instance, flip it to true while the rest to false, perform a GoRouter navigation using the item's route property, followed by a call to notifyListeners, causing this widget to rebuild, reflecting accurately the state of its items.

Take it for a spin and lets see:

App

That was state management in a nutshell!!!

With all of the major pieces hooked up, let's continue setting up our pages; let's go back to our WelcomePage page to personalize it more.

Let's give some love to some of our pages, starting by the welcome page.

App

Here's the schematics of what we'll be building:

App

Back on the WelcomePage class, replace the contents of our existing Container by a Column widget, with its contents vertically and horizontally centered:

//... inside the Center widget, add a Column:

Column(
    crossAxisAlignment: CrossAxisAlignment.center,
    mainAxisAlignment: MainAxisAlignment.center,
    children: []
)

NOTE: ALL CODE BELOW WILL BE INSIDE THE COLUMN ABOVE, SO FOR BREVITY WE WILL OMIT THE COLUMN DEFINITION

As the first child of the Column, add a Row widget, which will hold both our image and the waving hand icon.

Let's go through the code we will be building:

The code is below, ready to be copied for convenience:

//... inside the **Column** widget, as its first child:

Row(
    mainAxisSize: MainAxisSize.min,
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
        Container(
        width: 100,
        height: 100,
        decoration: BoxDecoration(
            border: Border.all(
                color: PersonalPortfolioColors.mainBlue,
                width: 8 
            ),
            borderRadius: BorderRadius.circular(100),
            image: const DecorationImage(
                image: NetworkImage('https://avatars.githubusercontent.com/u/5081804?s=400&u=04dc8bfa749d69165ab08ffba89edd5f095ba21d&v=4'),
                fit: BoxFit.cover
            )
          ),
        ),
        const SizedBox(width: 20),
        const Icon(
            PersonalPortfolioIcons.wave, 
            size: 90,
            color: PersonalPortfolioColors.lightBlue
        )
    ]
),

Test it, make sure the image and icon display fine:

App

Proceed with the second portion of this page, by adding some vertical spacing (20px), followed by a Column widget, with its items horizontally center. Start by adding a Text widget as the first child of this Column.

The Text widget contains the text "Hello," with some stylings applied.

//.. under the header content Row widget:

// some spacing:
const SizedBox(height: 20),

// the column 
Column(
    mainAxisSize: MainAxisSize.min,
    crossAxisAlignment: CrossAxisAlignment.center,
    children: [
        const Text('Hello,', style: 
            TextStyle(
              fontSize: 100,
              fontWeight: FontWeight.bold,
              color: Colors.white
            )
        ),
    ]
),

Let's continue adding items, now we'll add a Text.rich widget - which we're using to mix text styles within the same string. Here we want to show the string "I'm" normal, while the name "Roman" we want it bold. Both of them though should be horizontally centered:

//... under the "Hello," Text widget:

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,
),

Test it out to see the progress:

App

Lastly, we'll be adding the lower part of this layout, which will be some spacing, and a Row widget with a badge icon and a title divided into two lines via another Column widget:

//... right under the **Text.rich** icon:

Row(
    mainAxisSize: MainAxisSize.min,
    crossAxisAlignment: CrossAxisAlignment.start,
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
        const Icon(
            PersonalPortfolioIcons.badge,
            color: PersonalPortfolioColors.lightBlue,
            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('Cloud Architect', textAlign: TextAlign.center, style: TextStyle(fontSize: 40, color: Colors.white)),
            ],
        )
    ]
),

Test it again and you should see the following:

App

So far the Welcome Page is not that welcoming :(. Our page has a bunch of stuff hardcoded and static - we are not truly leveraging the power of Flutter, which is rendering widgets and making delightful, engaging experiences at 60 frames per second.

We want to greet our users in many languages, and bring some aspects of dynamism to this page. Let's use our newly acquired skills in state management so we can flip through a series of greetings and show it to the user.

We want to invite them in. Let's also add some animations to it to make it look more inviting. See below what we'll accomplish:

App

Let's start!

Adding a service for our WelcomePage

We'll proceed by creating a service for the WelcomePage page widget so as to maintain its state while the widget rebuilds and becomes more interactive.

In the services folder, add a file called welcome_page_service.dart with a class called WelcomePageService - extend ChangeNotifier so it triggers relevant notifications instructing the welcome page to rebuild its widgets upon state change.

Add the following properties on it:

Do the proper imports while you bring the following piece of code into the file:

class WelcomePageService extends ChangeNotifier {

  String name = 'Roman';
  String title1 = 'Flutter GDE';
  String title2 = 'Cloud Architect';

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

  static List<String> greetings = [
    "Hello", "Hola", "Bonjour", "Olá", "Ciao", "Namaste", "Kon'nichiwa"
  ];

  int greetingsCounter = 0;
  
  String currentGreeting = greetings[0];
}

Inside this same class, we need to kick off the cycling of the greeting labels by starting the timer, so let's create a method called initializeGreetings, which will perform the following tasks:

Add the following code in the class:

// ... inside the WelcomePageService class...

void initializeGreetings() {

    // cycle through the list of greetings every second
    greetingsTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
        if (greetingsCounter == greetings.length) {
            greetingsCounter = 0;
        }

        currentGreeting = greetings[greetingsCounter];
        greetingsCounter++;

        // notify the change to the UI so it rebuilds
        notifyListeners();
    });
}

Add an additional method to dispose / cancel the timer when the widget gets disposed. Create a method called disposeTimer which all it does is stop the timer by calling cancel() on it.

// ... inside the WelcomePageService class...

void disposeTimer() {
    greetingsTimer.cancel();
}

In the WelcomePageService class, override a dispose method, and add the disposeTimer call in there. This will ensure that when this service also gets disposed, it also disposes of those dependencies as well:

@override
void dispose() {
    disposeTimer();
    super.dispose();
}

Inject the WelcomePageService at the root of the app: MultiProvider

Before we can start consuming the newly created WelcomePageService we must inject it at the root of the app, via the MultiProvider by adding it to its list of providers.

Go to the main.dart file, and inside the runApp method, add the WelcomePageService as one of the services to be injected and accessible by all inherited widgets, as such:

//... add this as the last item in the list of Providers in the MultiProvider widget:

ChangeNotifierProvider(
  create: (_) => WelcomePageService(),
),

Your code should look like this after adding the WelcomePageService as a ChangeNotifierProvider provided service:

App

With this in place, lets go back to the UI to hook up this service to it.

Consume the WelcomePageService in the WelcomePage widget

Back in the WelcomePage widget, let's do some refactoring first.

Turn the WelcomePage from a StatelessWidget to a StatefulWidget since we need to initialize some of the functionality in the lifecycle methods initState and dispose so lets do it.

// refactor the class from a StatelessWidget to a StatefulWidget

class WelcomePage extends StatefulWidget {
  static const String route = '/welcome';

  const WelcomePage({super.key});

  @override
  State<WelcomePage> createState() => _WelcomePageState();
}

class _WelcomePageState extends State<WelcomePage> {

    //.. rest of the code
}

Now that we have our class turned into a StatefulWidget, let's create a reference of our WelcomePageService so we can initialize things on it.

At the top of the class, create a late property type WelcomePageService called serviceInstance; this will be late initialized and hold the reference to our service for initialization purposes. Do the proper imports as always:

// at the top of the _WelcomePageState class...

late WelcomePageService serviceInstance;

Override the initState method in the _WelcomePageState state class. Call super.initState() as customary and fetch a reference of our service class via the supplied context available in the state class. Then, call the method initializeGreetings() off of the service instance. You'll be asked to import the provider package here.

// ... override the initState

@override
void initState() {
    super.initState();

    // get a reference of the service using the context
    serviceInstance = context.read<WelcomePageService>();

    // invoke the initialization of the greetings
    serviceInstance.initializeGreetings();
}

In the dispose() method, stop the timer by using the same reference instance, and calling the method disposeTimer() on it, which stops the timer once this widget gets disposed:

// inside the _WelcomePageState class...

@override
void dispose() {
    serviceInstance.disposeTimer();
    super.dispose();
}

Now let's consume this service since all the hooks are in place.

Inside the build method, wrap the root Center widget inside a Consumer widget, type WelcomePageService, as such:

// wrap the Center widget inside a Consumer<WelcomePageService> instance...

return Consumer<WelcomePageService>(
    builder: (context, welcomeService, child) {
    return Center(
        /// ... rest of the code
    );
});

With that in place, now you can start pulling in the values from the service. Start by replacing the hardcoded string "Hello" by the currentGreeting property from the service. Refactor the Text widget by not making it const but making the text style const instead.

// replace the hardcoded "Hello" by using the currentGreeting property;
// this will display the current greeting in turn

//... 

Text(welcomeService.currentGreeting, style: 
    const TextStyle(
        fontSize: 100,
        fontWeight: FontWeight.bold,
        color: Colors.white
    )
),

//...

Take it for a spin and you should get your cycling greeting labels!!!

Go ahead and replace the name, title1 and title2 on your own in the corresponding widgets.

Adding animations to the waving hand

Now let's proceed and add a waving animation to the waving hand icon we have in place. We will perform a swaying animation from side to side, simulating a hand wave.

Let's go back to our service class WelcomePageService to add the dependencies there.

In order to have better control of our animations, we will use Explicit Animations. You can read more on those here.

In a nutshell, Explicit Animations are a set of controls for telling Flutter how to rapidly rebuild the widget tree while changing widget properties to create animation effects. This approach enables you to create effects that you can't achieve using implicit animations.

For implementing explicit animations, you need an AnimationController and a TickerProvider.

Start by adding a late AnimationController property called wavingAnimation to the WelcomePageService class:

/// inside the WelcomePageService class ...

late AnimationController wavingAnimation;

Create a method called initializeAnimation that takes a TickerProvider as a parameter. What this method does is:

//... inside the WelcomePageService class...

void initializeAnimation(TickerProvider provider) {
    wavingAnimation = AnimationController(vsync: provider,
    duration: const Duration(milliseconds: 500)
    )..repeat(reverse: true);
}

You cannot forget to dispose of any controllers used in your app, since it may result in memory leaks and unexpected behavior. Create a method called disposeAnimation which all it does is dispose of the AnimationController instance, as such:

//... inside the WelcomePageService class:

void disposeAnimation() {
    wavingAnimation.dispose();
}

Also in the service class, go to its dispose() method and add the disposeAnimation invocation there as well:

// inside the existing **void dispose()** add the disposeAnimation()
// ADD THIS LINE

disposeAnimation():

Let's go back to our WelcomePage widget and wrap things up. Add the SingleTickerProviderStateMixin mixin to the existing class, which turns it into a TickerProvider.

Your code should look like this:

App

A TickerProvider will provide a Ticker to the AnimationController so we can hook up to Flutter's frame rendering scheduler, via a mixin called TickerProviderStateMixin, allowing the controller to tap into the frame rendering mechanism and make a change to the animation state on every frame rendered, resulting in the simulation of movement.

In the initState(), call the newly added method initializeAnimation, passing this to it, which will allow to grab a ticker from this instance for tapping into Flutter's internal frame scheduler, thus performing the animation.

//.. inside the initState() method, under the
//.. after getting the serviceInstance reference...

serviceInstance.initializeAnimation(this);

Don't forget to add the disposeAnimation invocation also on this widget's dispose method!

//... inside the WelcomePageState's dispose method,
// add this before super.dispose()

serviceInstance.disposeAnimation();

Now that we have a way to properly initialize the animation and dispose of it; let's use it.

Locate the waving hand icon in the widget structure, and wrap it inside a RotationTransition explicit animation widget, and provide the required arguments as such:

Grab the whole code below and replace the existing Icon widget, as such:

// replace the Icon widget with this:

RotationTransition(
    alignment: Alignment.bottomCenter,
    turns: Tween<double>(begin: -0.09, end: 0.005)
        .animate(CurvedAnimation(parent: welcomeService.wavingAnimation, curve: Curves.easeInOut)),
    
    child: const Icon(PersonalPortfolioIcons.wave, size: 90, color:             PersonalPortfolioColors.lightBlue),
),

One minor tweak - add more spacing to the left of this RotationTransition by increasing the SizedBox from 20px to 40px, which will give the waving animation a bit of room to breathe:

//... to the SizedBox next to the RotationTransition:

const SizedBox(width: 40),

And voilá! We've got a pretty decent welcome page, with animations and dynamic content being displayed! We rock!

App

So far we've only developed fully the WelcomePage widget. In order to keep things cohesive, let's further develop the rest of the pages.

We'll keep things simple for now - each page will just show its corresponding icon, a title and a subtitle. Feel free to improve on the layout and content of each one of the pages.

We'll use the following structure on each of the pages (TwitterPage, LinkedInPage, GithubPage, and WebPage):

You can check out the widget structure content below; as a challenge, go ahead and complete the other pages adding the corresponding content:

//... inside the build method of each of the pages...

var mainTitle = 'Your main title';
var subTitle = 'Your subtitle';
IconData icon = Icons.abc; // Add the icon from the PersonalPortfolioIcons mapping

return Center(
    child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisSize: MainAxisSize.min,
        children: [
            Icon(icon, size: 80,
            color: PersonalPortfolioColors.lightBlue),
            Text(mainTitle,
                textAlign: TextAlign.center,
                style: const TextStyle(
                    fontSize: 100,
                    fontWeight: FontWeight.bold,
                    color: Colors.white
                )
            ),
            Text(subTitle, style: 
                const TextStyle(
                    fontSize: 40,
                    color: PersonalPortfolioColors.lightBlue
                )
            ),
        ],
    )
);

Challenge: Populate the rest of the pages

As a challenge, let's see if you've picked up a thing or two from this session, therefore we'll request from you the following:

App

Good Luck!

If you completed the bonus section before, you may have noticed the content in those pages is still hardcoded inside the page widget, which is a bad practice unless that's your intention.

The correct approach is to move the content that feeds these widgets to a separate service or view model using some sort of state management approach - we've been using Provider, so you could follow the same approach we did earlier: create a service class inside the services folder and feed it at the top of the widget structure via the MultiProvider widget.

I'll do the first one, you do the rest - deal?

Let's do the TwitterPageService service. Create a file called twitter_page_service.dart and inside, a class called TwitterPageService. Since this class will not be publishing updates to its state but merely hold the state for our page, no need extending ChangeNotifier.

twitter_page_service.dart

import 'package:flutter/material.dart';
import 'package:roman_web_portfolio/helpers/personal_portfolio_icons_icons.dart';

class TwitterPageService {

  String mainTitle = "Follow me";
  String subTitle = "@drcoderz";
  IconData icon = PersonalPortfolioIcons.twitter;
}


Next, register this service at the root of the project, in the main method's runApp method, right inside the MultiProvider. Add an additional provider wrapper (Provider) that returns our newly created provided service TwitterPageService, as such:

NOTE: Do the proper imports!

//... inside the **providers** array of the MultiProvider, add:

Provider(
    create: (_) => TwitterPageService(),
),

With that set, go to the TwitterPage widget, and inside the build method, as the first line of the method, fetch an instance of this service via its context, using the read() method, which traverses up the widget hierarchy until it finds an instance of TwitterPageService. Store the fetched instance in a local property called twitterPageService.

Follow this call by populating the corresponding existing properties, as such:

//... inside the TwitterPage's build method:

var twitterPageService = context.read<TwitterPageService>();

var mainTitle = twitterPageService.mainTitle;
var subTitle = twitterPageService.subTitle;
IconData icon = twitterPageService.icon;


Run the app again - you shouldn't notice any changes - but you know it is correctly built now; without hardcoded values in the UI! With this, you've future-proofed your application and made it highly maintainable.

Go ahead and do this for the rest of the pages - good luck!

In this codelab, we accomplished the following:

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

The complete code is available on Github by following this link.

Happy Coding!!!