Flutter TDD Clean Architecture Course [14] – User Interface

34  comments

The time for putting all of our previous work into practice is finally here! This part will be all about building the UI and splitting it into multiple readable and maintainable Widgets.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

If you need a refresher on how the UI looks like...

Creating a Page

The number trivia feature (and in turn the whole app) will have a single page, which will be a StatelessWidget called NumberTriviaPage. Let's just create a blank version of it for now.

.../presentation/pages/number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
    );
  }
}

All this time the main.dart file contained the example counter app. Let's change that! We're going to delete everything and create pristine MaterialApp with some basic green (Reso Coder style ?) theming. Of course, the home widget will be the NumberTriviaPage.

main.dart

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Number Trivia',
      theme: ThemeData(
        primaryColor: Colors.green.shade800,
        accentColor: Colors.green.shade600,
      ),
      home: NumberTriviaPage(),
    );
  }
}

Getting the Presentation Logic Holder

In Clean Architecture, the only communication pathway between UI widgets and the rest of the app is the presentation logic holder. Number Trivia App uses Bloc, but as I'm saying probably for the 100th time, you can use anything from Change Notifier to MobX.

Regardless of your preferred state management method, you still need to provide your presentation logic holder throughout the widget tree. For that, you can obviously use the provider package! Since we're using Bloc which is integrated with provider, we're going to use a special BlocProvider which has some nice Bloc-specific features.

The NumberTriviaBloc has to be available to the whole body of the page's Scaffold. This is where we will get the registered NumberTriviaBloc instance from the service locator, which will in turn kick off all of the lazy singletons registered in the previous part.

number_trivia_page.dart

...
import '../../../../injection_container.dart';
import '../bloc/bloc.dart';

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: BlocProvider(
        builder: (_) => sl<NumberTriviaBloc>(),
        child: Container(),
      ),
    );
  }
}

Finished UI

Building Out the Content

Before reacting to States emitted by the NumberTriviaBloc, let's first build out a basic UI outline using Placeholder widgets.

The root of the UI will be a padded and centered Column comprised of two basic parts:

  • The top half will be concerned with output. There will be a message containing either the trivia itself, some sort of an error or even the initial call to "Start searching!". The CircularLoadingIndicator will also be displayed in the top part. This part of the UI will be rebuilt whenever the state changes.
  • The bottom part will deal with input. It will hold a TextField and two RaisedButtons.

Outlining with Placeholders

Designing the UI layout with Placeholders is a perfect way to appropriately set the spacing and sizes of the widgets without having to think about their implementation. At this point, it will also be the best to extract the body of the Scaffold​​ into its own helper buildBody method.

Shaping it with placeholders

number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: buildBody(context),
    );
  }

  BlocProvider<NumberTriviaBloc> buildBody(BuildContext context) {
    return BlocProvider(
      builder: (_) => sl<NumberTriviaBloc>(),
      child: Center(
        child: Padding(
          padding: const EdgeInsets.all(10),
          child: Column(
            children: <Widget>[
              SizedBox(height: 10),
              // Top half
              Container(
                // Third of the size of the screen
                height: MediaQuery.of(context).size.height / 3,
                // Message Text widgets / CircularLoadingIndicator
                child: Placeholder(),
              ),
              SizedBox(height: 20),
              // Bottom half
              Column(
                children: <Widget>[
                  // TextField
                  Placeholder(fallbackHeight: 40),
                  SizedBox(height: 10),
                  Row(
                    children: <Widget>[
                      Expanded(
                        // Search concrete button
                        child: Placeholder(fallbackHeight: 30),
                      ),
                      SizedBox(width: 10),
                      Expanded(
                        // Random button
                        child: Placeholder(fallbackHeight: 30),
                      )
                    ],
                  )
                ],
              )
            ],
          ),
        ),
      ),
    );
  }
}

Top Half - Displaying Data

The top half of the screen has to display different widgets depending on the State outputted from the NumberTriviaBloc. Loading will display a progress indicator, Error will, of course, display an error message etc. Building out different widgets according to the current state of the Bloc is possible with a BlocBuilder.

The initialState of NumberTriviaBloc is Empty, so let's first handle that one by returning a simple Text wraped inside a Container to make sure it takes up a third of the screen height.

number_trivia_page.dart

...
// Top half
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return Container(
        // Third of the size of the screen
        height: MediaQuery.of(context).size.height / 3,
        child: Center(
          child: Text('Start searching!'),
        ),
      );
    }
    // We're going to also check for the other states
  },
),
...

MessageDisplay

Apart from the text being small, the Empty state is handled. However, let's first clean up the mess we created by extracting the Container into its own MessageDisplay widget. We will use this widget to also display the error messages when the Error state is emitted, so we have to make sure a String message can be passed through the constructor.

In addition, we're going to add a bit of styling to make the text bigger and also scrollable using a SingleChildScrollView. Otherwise, long messages would get cut off because of the Container which has a limited height.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    }
  },
),

...

class MessageDisplay extends StatelessWidget {
  final String message;

  const MessageDisplay({
    Key key,
    @required this.message,
  })  : assert(message != null),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      // Third of the size of the screen
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: SingleChildScrollView(
          child: Text(
            message,
            style: TextStyle(fontSize: 25),
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

It's only natural to reuse this MessageDisplay for messages coming from the Error state as well. We'll expand the BlocBuilder with an additional else if clause:

number_trivia_page.dart

BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),
We aren't going to use the MessageDisplay for the Loaded state to display the actual NumberTrivia, because we want to have the number displayed nicely on the top.

LoadingWidget

Getting data from the remote API takes some time. That's why the Bloc emits a Loading state and it's the responsibility of the UI to display a loading indicator. We already know that putting long-winded widgets directly into the BlocBuilder hinders readability, so we're immediately going to extract the Container into a LoadingWidget.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Loading) {
      return LoadingWidget();
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),

...

class LoadingWidget extends StatelessWidget {
  const LoadingWidget({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}
Every custom widget in the top half of the screen will be take up precisely one third of the height of the whole screen using MediaQuery. Fixating the height is useful to prevent the bottom half from "jumping around" when the length of the displayed message / trivia changes.​​

TriviaDisplay

We're missing one "state-reaction" implementation for the most important state of them all - the Loaded state which contains the NumberTrivia entity which the user is interested in.

The TriviaDisplay widget will be extremely similar to MessageDisplay, but of course, it will take in a NumberTrivia object throught the constructor. Also, TriviaDisplay will be made of a Column displaying two Text widgets. One will display the number in a big font, the other one will display the actual trivia.

number_trivia_page.dart

...
BlocBuilder<NumberTriviaBloc, NumberTriviaState>(
  builder: (context, state) {
    if (state is Empty) {
      return MessageDisplay(
        message: 'Start searching!',
      );
    } else if (state is Loading) {
      return LoadingWidget();
    } else if (state is Loaded) {
      return TriviaDisplay(
        numberTrivia: state.trivia,
      );
    } else if (state is Error) {
      return MessageDisplay(
        message: state.message,
      );
    }
  },
),

...

class TriviaDisplay extends StatelessWidget {
  final NumberTrivia numberTrivia;

  const TriviaDisplay({
    Key key,
    this.numberTrivia,
  })  : assert(numberTrivia != null),
        super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      height: MediaQuery.of(context).size.height / 3,
      child: Column(
        children: <Widget>[
          // Fixed size, doesn't scroll
          Text(
            numberTrivia.number.toString(),
            style: TextStyle(
              fontSize: 50,
              fontWeight: FontWeight.bold,
            ),
          ),
          // Expanded makes it fill in all the remaining space
          Expanded(
            child: Center(
              // Only the trivia "message" part will be scrollable
              child: SingleChildScrollView(
                child: Text(
                  numberTrivia.text,
                  style: TextStyle(fontSize: 25),
                  textAlign: TextAlign.center,
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

Minor Refactoring

We've already done a lot to keep the code maintainable by creating custom widgets. Currently though, they are all inside the number_trivia_page.dart file, so let's just simply move them into their own files under the widgets folder​​.

Number Trivia feature - presentation folder

The widgets.dart file is something called a barrel file. Since Dart doesn't support "package imports" like Kotlin or Java do, we have to help ourselves to get rid of a lot of individual imports with barrel files. It simply exports all of the other files present inside the folder.

widgets.dart

export 'loading_widget.dart';
export 'message_display.dart';
export 'trivia_display.dart';

And then, inside number_trivia_page.dart it's enough to import just the barrel file:​​

import '../widgets/widgets.dart';

Bottom Half - Receiving Input

All of the widgets we've made up to this point would be of no use unless the user could initiate fetching of random or concrete NumberTrivia. Because we're using Bloc, this will happen by dispatching events.

Currently, the bottom half of the UI is full of Placeholders but even before replacing them with real widgets, let's separate out the whole bottom half Column into a custom TriviaControls stateful widget.

number_trivia_page.dart

class TriviaControls extends StatefulWidget {
  const TriviaControls({
    Key key,
  }) : super(key: key);

  @override
  _TriviaControlsState createState() => _TriviaControlsState();
}

class _TriviaControlsState extends State<TriviaControls> {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        // Placeholders here...
      ],
    );
  }
}

Why stateful? TriviaControls will have a TextField and in order to deliver the inputted String to the Bloc whenever a button is pressed, this widget will need to hold that String as local state.

In addition to dispatching the GetTriviaForConcreteNumber event when the concrete button is pressed, this event will get dispatched also when the TextField is submitted by pressing a button on the keyboard.​​

number_trivia_page.dart

class _TriviaControlsState extends State<TriviaControls> {
  final controller = TextEditingController();
  String inputStr;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextField(
          controller: controller,
          keyboardType: TextInputType.number,
          decoration: InputDecoration(
            border: OutlineInputBorder(),
            hintText: 'Input a number',
          ),
          onChanged: (value) {
            inputStr = value;
          },
          onSubmitted: (_) {
            dispatchConcrete();
          },
        ),
        SizedBox(height: 10),
        Row(
          children: <Widget>[
            Expanded(
              child: RaisedButton(
                child: Text('Search'),
                color: Theme.of(context).accentColor,
                textTheme: ButtonTextTheme.primary,
                onPressed: dispatchConcrete,
              ),
            ),
            SizedBox(width: 10),
            Expanded(
              child: RaisedButton(
                child: Text('Get random trivia'),
                onPressed: dispatchRandom,
              ),
            ),
          ],
        )
      ],
    );
  }

  void dispatchConcrete() {
    // Clearing the TextField to prepare it for the next inputted number
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context)
        .dispatch(GetTriviaForConcreteNumber(inputStr));
  }

  void dispatchRandom() {
    controller.clear();
    BlocProvider.of<NumberTriviaBloc>(context)
        .dispatch(GetTriviaForRandomNumber());
  }
}

Of course, let's again put this widget into its own trivia_controls.dart file and then add that as an export​​ into the widgets.dart barrel file.​​​​

Last Fixes

It works! ? It works!? It works! ? Hooray! The user can finally input a number and get a concrete trivia or he can just press the random button and get some random trivia. There's one UI bug though which becomes immediately apparent when we open a keyboard:

Keyboard causes the screen height to "shrink"

This can be fixed very easily with a SingleChildScrollView wrapping the whole body of the Scaffold. With that, whenever the keyboard appears and the body shrinks in height, it will become scrollable and no overflow will happen.​​​​

number_trivia_page.dart

class NumberTriviaPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Number Trivia'),
      ),
      body: SingleChildScrollView(
        child: buildBody(context),
      ),
    );
  }
  ...
}

Are we done?

In these 14 parts of the Clean Architecture course, we've gone from an idea to a working app. You learned how to architect your apps into independent layers, how to do TDD, discovered the power of contracts, learned dependency injection and much more.

The Number Trivia App we've built may be on the simpler side when it comes to its functionality, what's important though are the principles. What you learned in this course is applicable to all kinds of circumstances and I believe you are now an overall better developer. Practice the principles you learned here and build something awesome!

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter Developer at LeanCode and a developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

Flutter UI Testing with Patrol

Flutter UI Testing with Patrol
  • Hello! First I would like to thank you for the excellent course! I followed all classes and did exactly the same, but in my “mapEventToState” class NumberTriviaBloc, is always returning the state Empty

  • Thank you Matt for all your efforts in teaching us Clean Architecture with TDD and all your other Tutorials!

    I am a newbie with flutter, and after learning the basics, will surely come back here for using clean architecture and TDD when developing the app to develop.

  • Your tutorial is really awesome and very helpful. I have one query, Is there any tutorial to learn how to test widgets with your bloc pattern?

  • Very helpful, but I have this error when trying to dispatch the event from BlocProvider:

    BlocProvider.of() called with a context that does not contain a Bloc of type NumberTriviaBloc.
    No ancestor could be found starting from the context that was passed to
    BlocProvider.of().

    Maybe something changed in the BLoC package?

    Thanks

    • I’m not sure why that’s happening but it looks like you’re not providing the NumberTriviaBloc using a BlocProvider. I know this is probably not helpful but I don’t have any more info.

  • Thank you for this awesome tutorial, i have one question.
    If we are using provider with chopper, de we need to pass the BuildContext every time through all layers to the data to calls Provider.of ?

  • Hi Matt, and thank you for your tutorial I have one problem, the loading state of the Bloc is not changing even the data is completely gotten.

  • Great tutorials, I really enjoyed your tutorial series. a lot of essential information in chunked concise format. Keep going

  • Hi, As soon as I replace the container with BlocBuilder, I get an error.
    The _ScaffoldLayout custom multichild layout delegate forgot to lay out the following child:
    _ScaffoldSlot.body: RenderPositionedBox#19002 NEEDS-LAYOUT NEEDS-PAINT NEEDS-COMPOSITING-BITS-UPDATE
    parentData: offset=Offset(0.0, 0.0); id=_ScaffoldSlot.body
    constraints: MISSING
    size: MISSING

  • Just finished Clean Architecture TDD Series.
    Thanks, very nice content.
    Just one question if Entity/Model can be used across multiple Features, how should be the Folder structure inside Lib?

  • Just finished Clean Architecture TDD Series.
    Thanks, Nice content. Thumbs up
    One doubt if there is same entity/model which can be used in other Features, how should be the Folder Structure or Where it (Entity/Model) should be placed?

  • Man thanks for sharing your knowledge in this way. Indeed I am more confident to write a code! Thanx

    • – In the TextField onSubmitted: (_) => (inputString.isNotEmpty) ? dispatchConcrete() : null,

      – In the Search RaisedButton onPressed: (inputString.isNotEmpty) ? dispatchConcrete : null,

  • Thank you Matt for all your efforts in these tutorials!

    Would you please tell us some open source projects which is following these principles?
    I think it could be help to getting more experience on Clean Architecture design on flutter.

  • Thanks for your helpful content. But I get this error

    error: The method ‘sl’ isn’t defined for the type ‘NumberTriviaPage’. (undefined_method at [clean_architecture] lib/features/number_trivia/presentation/pages/number_trivia_page.dart:23)

    sl is not being recognised by the BlocProvider. Can you help me find a way around it. Thanks in advance.

    • I Got the same error, and just adding ‘()’ to the http.Client i solved

      Like this——->> sl.registerLazySingleton(() => http.Client());

      this line is on the injection_container.dart File.

      If you already have it, check to see if there is any class that is not instantiated.

  • Hi everyone !

    Firstly, I’d like to thank you for this amazing tutorials and your 7 hours video.
    It was awesome, I learn so much ! Thank you very much, it’s a huge work !

    I’d like to help others who have a problem loading data.
    I’ve found that data_connection_checker library always return false when you are in debug mode (apparently it works on release mode).
    To prevent this issue, I replace the code in the network_info.dart by:

    import ‘dart:io’;

    abstract class NetworkInfo {
    Future get isConnected;
    }

    @override
    class NetworkInfoImplementation implements NetworkInfo {
    @override
    Future get isConnected async {
    try {
    final list = await InternetAddress.lookup(‘google.com’);
    return list.isNotEmpty && list[0].rawAddress.isNotEmpty;
    } on SocketException catch (_) {
    return false;
    }
    }
    }

    This way, you can prevent this issue and retrieve correctly your trivia number 🙂
    Don’t forget to remove the test in the core folder, now useless.
    You can remove also the data_connection_checker library in your pubspec.

    And replace in the injection_container.dart:
    sl.registerLazySingleton(() => NetworkInfoImplementation(sl()));
    by:
    sl.registerLazySingleton(() => NetworkInfoImplementation());

    Moreover, don’t forget to add the

    key, in your AndroidManifest.xml 😉

    And that’s it, the project should work now !
    Hope it will help you !

    Thanks again for your contents, it’s really amazing!
    Have a nice day!

    • I think that for security reason, the key doesn’t appear in my previous comment, you can find it here:
      https://github.com/flutter/flutter/issues/20789

      “Don’t forget to remove the test in the core folder, now useless.”
      I mean, the the network_info_test.dart file in the core/network folder 🙂
      Now useless because we remove the data_connection_checker library

  • I found errors when querying with the empty TextField, to fix it just add this:

    – In the TextField onSubmitted: (_) => (inputString.isNotEmpty) ? dispatchConcrete() : null,

    – In the Search RaisedButton onPressed: (inputString.isNotEmpty) ? dispatchConcrete : null,

  • Finally, I completed this course. (Alhamdulillah)
    I did this course in August 2021. About 2 years later. So, I fetched many issues, bugs.
    I think, it is difficult for beginners to do this course.

    Whatever, I learned many things from this course. (I am intermediate Flutter developer. I had a problem, I couldn’t finish project. After finishing about half of project, I made noise, I became depressed.) But now, I am feeling confidence?. I understand how to do!!

    Thanks Matt??

    • Thanks Matt for this awesome course!! It was very helpful for a beginner like me!

      As well as @Prantik I finished it on 2021 and most of the libraries had some updates such as Dart 2.0, BLoC 7.3, get_it 7.2 and so on. It was challenging to make the project work but in the end I made it :). I also combined it with the injectable tutorial that you made and it works amazingly well ?

      I leave you the link to my repository in case you all want to take a look on how to make this project work with new dependencies versions (until the day of this post).

      Let me know what you think 🙂

      https://github.com/jmalovera10/flutter-clean-architecture

  • Thanks for this course!

    Would you recommend this structure and all these packages in 2022? Or would you change anything?

  • Thanks for your tutorial! You taught me a lot about tdd and clean architecture. Since this material was implemented in 2019, some things changed since then: null safety, libraries deprecated and etc.

    I’m gonna clone your repository, make the necessary changes and open a pull request, but since this is old and you would have to edit the video description/blog posts, I don’t know if you’re going to change it.

    In the meanwhile, if the readers are looking for a version of the app working in 2022 (after null safety update), check my github.

    https://github.com/farvic/tdd-clean-number-trivia-app

    Credits for the tutorial and original material belong to the author.

  • Thanks for your tutorial! Matej, i learnt a lot about tdd and clean architecture.

    2023 many dependencies has been deprecated and all.
    if anyone needs the updated version of the code as of 2023, you can check the repository below, all credits to the original owner.

    https://github.com/ribak100/NumberTrivia

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >