3

Flutter TDD Clean Architecture Course [14] – User Interface

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!

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Awesome series Matej !

    I’ll redirect everyone that wants to learn Flutter over here.

  • Gabriel Soares Basilone Paiva says:

    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

  • Viktor Borbely says:

    Thank you Matej!

    This type of course I was looking for Flutter development.
    Keep up your good job done here!

  • >