I Am Rick (Episode 6): Rick’s Protein Intake Calculator

Building a health app with Flutter and Rick Grimes.

Alexandros Baramilis
24 min readMar 6, 2020

Intro

If you haven’t been following the series so far, you can check out the previous episodes:

or the Github repo for the series.

If you’re having trouble installing Flutter on macOS Catalina, check out Setting up Flutter on macOS Catalina.

ALERT: Next episodes are out!

As to why I’m writing this series:

One of the best ways for me to learn something is to write about it, so I’m making this series with the notes that I take as I go through App Brewery’s Flutter Bootcamp. Publishing my notes and code also forces me to maintain a high standard and allows me to easily go back for a quick review or to update them. I’m keeping the Rick Grimes theme alive for as long as I can, because it just makes it so much more fun. Don’t worry, App Brewery’s bootcamp doesn’t have Rick in it, but I still highly recommend it if you want to learn Flutter. 😄

Rick’s Protein Intake Calculator

As Rick’s community was growing he was facing a big problem.

How to distribute their scarce resources optimally across the group?

After all, people of different genders, ages and sizes have different energy requirements. And when you’re out there fighting walkers or working the fields, you spend more energy than being on guard duty or inventory.

It’s clear that it doesn’t make sense to split the rations evenly across the group, especially when they are not that plentiful.

So Rick, after visiting an abandoned library to read up on nutrition science, and after consulting with tech guru Eugene Porter, decided to build a protein intake calculator app, to determine exactly how much protein each member of the group needs in order to perform their duties optimally.

The Calculator Algorithm

Before we start coding, let’s determine how the algorithm will work so that we know what input data we need, as well as what output data we will have.

I based the algorithm roughly on this calculator, because it seems like a decent website in providing unbiased, science-based information i.e. not a website trying to sell you tons of protein powder. If you’re interested in reading more, they also have a more in-depth article.

Our calculator will work with metric units so we don’t have to worry about conversions.

I’m also skipping the part about pregnant and lactating women for simplification. The intake for men and women is the same otherwise, so we don’t need the gender information either.

I will replace the question of ‘are you healthy weight vs overweight/obese’ with an input for height so we can calculate the BMI instead of asking the user to know it.

So our inputs are:

  • Weight (in kg)
  • Height (in cm)
  • Activity Level (active vs. sedentary)
  • Fitness Goal (maintenance, muscle gain or fat loss)

The optimal daily protein intake for adults is summarised in the table below.

https://examine.com/nutrition/protein-intake-calculator/

So basically, using the weight and height, we calculate the BMI and then based on the user’s answers for activity level and fitness goal, we get the recommended grams per kilogram of body weight. We multiply that value by the user’s weight to get the recommended daily intake of protein in grams.

For the outputs we will have:

  • A range value with a lower bound and an upper bound (except for the healthy weight + sedentary case where we will only have a lower bound)
  • I would also like to show the text in the asterisks from the table above, based on the result

Finally, the formula to calculate the BMI is:

BMI = weight (kg) / [height (m)]²

According to the CDC, for adults 20 years old and older, BMI is interpreted using standard weight status categories. These categories are the same for men and women of all body types and ages.

The standard weight status categories associated with BMI ranges for adults are shown in the following table.

https://www.cdc.gov/healthyweight/assessing/bmi/adult_bmi/index.html

Based on this table, if we have a BMI of 24.9 or below we’ll treat it as healthy weight and if we have a BMI of 25.0 or above we’ll treat it as overweight/obese.

If you’re interested in child and teen BMI, you can read more here.

I think it’s needless to say that the information presented here is not medical advice, but I’m saying it anyway!

Ok I think we have enough information now to start building, so on to the technical stuff!

Flutter Themes

So far we have been providing the style for each widget separately, which can be tiring, repetitive and adds unnecessary volume to our code.

With Themes we can share colours and font styles throughout the app.

The guide in the above link is part of the Flutter Cookbook, which is a collection of helpful guides to solve common problems in Flutter organised in different categories.

To change the theme for the whole MaterialApp, we just set its theme property.

The default value is ThemeData.light(). We can change it to ThemeData.dark() if we want the dark mode, or we can specify our own ThemeData widget for a custom theme.

We can go through the ThemeData docs to see all the different properties that we can set. (hint: it’s a lot of properties!)

I’ll start with a new StatelessWidget that will return a MaterialApp whose theme property we will set to a new ThemeData widget. For its home property I made a new StatefulWidget called InputPage that will hold the state of the app. It returns a SafeArea with a Scaffold with an AppBar with a Text title and a body with an empty Container.

To begin with, I’ll set the primaryColor property of the new ThemeData widget to our usual Walking Dead red (Colors.red[800]). The primaryColor is the background color for major parts of the app (toolbars, tab bars, etc).

There’s also the backgroundColor, which is the color that contrasts with the primaryColor, e.g. used as the remaining part of a progress bar, but to change the background of the Scaffold, we need to set the scaffoldBackgroundColor to Colors.lightGreen[200].

A really neat trick taught by Angela in the course is setting a theme using the copyWith() method. Say you wanted to copy all the theme properties of dark mode, but with a few tweaks. All you need to do is call the copyWith method on the dark ThemeData and just specify the properties you want to change. It’s a form of theme polymorphism :)

return MaterialApp(
theme: ThemeData.dark().copyWith(
primaryColor: Colors.red[800],
scaffoldBackgroundColor: Colors.lightGreen[200],
),

And even if you set a MaterialApp-wide theme, you can still tweak specific widget themes by wrapping them in a Theme widget and setting its data property to a new ThemeData widget.

Finally, for this part we’re going to separate the InputPage code into a different dart file called input_page.dart. The only thing we need to do is to copy and paste the relevant code into a different file, adding the import ‘package:flutter/material.dart’; statement on top and then importing the file in main.dart adding import ‘input_page.dart’; on top.

Creating Custom Widgets

I’m gonna start building the interface now.

The basic layout consists of a Column with four Rows inside that correspond to the four inputs that we require from the user.

So I will replace the Container that we had with a Column widget and for its children I will give it four RowCard widgets.

What are RowCard widgets?

A custom widget that I created for this specific purpose.

body: SafeArea(
child: Column(
children: <Widget>[
RowCard(isFirstRow: true),
RowCard(),
RowCard(),
RowCard(),
],
),
),

To create a custom widget we just create a StatelessWidget (or StatefulWidget).

class RowCard extends StatelessWidget {
RowCard({this.isFirstRow = false});
final bool isFirstRow; @override
Widget build(BuildContext context) {
return Expanded(
child: Container(
margin: EdgeInsets.fromLTRB(8.0, isFirstRow ? 8.0 : 0.0, 8.0, 8.0),
decoration: BoxDecoration(
color: Colors.lightGreen[500],
borderRadius: BorderRadius.circular(10.0),
),
),
);
}
}

I just gave it one property for now, a bool called isFirstRow. This is a small hack so that we have even margins throughout the layout. So if it’s the first row of the layout, we set this property to true. Then using the ternary operator isFirstRow ? 8.0 : 0.0 we set the top margin of the row. This means that if isFirstRow is true, the top margin will be 8.0, if it’s false, the top margin will be 0.0. For all the other rows that are not the first row, the top margin will be 0.0, but they’ll have the space from the bottom margin of the row above them. If we didn’t do that, the space between the rows would be 16.0, which is a bit uneven. (I know it’s OCD for some, but it hurts my eyes 😂).

I’m also setting a default value for isFirstRow inside the constructor, so we don’t have to specify this parameter for every row, just for the top one, making it an optional property. If on the other hand, we wanted to make this a required property, we would need to precede it with the @required keyword, like RowCard({@required this.isFirstRow}); .

I’m also setting the decoration property of the Container to a BoxDecoration widget in order to set the borderRadius of the Container so we can have rounded corners.

(Note: If you’re setting the decoration property, you can’t also set the color property, because the color property originally belongs to the BoxDecoration widget. Instead, you have to set the color property of the BoxDecoration widget.)

Because we wrapped the Containers in Expanded widgets, we get a layout that expands to take up the whole height of the screen.

Using the custom RowCard widget

It’s also a good idea to move the RowCard code into a separate row_card.dart file to keep our code clean and modular.

Dart final vs. const

Flutter stateless widgets are immutable. That means that they cannot be changed. When a change needs to happen, the widget is destroyed and replaced with a new widget that includes the changes.

The same applies to the properties of stateless widgets. This is why we have to mark the properties of stateless widgets with the keyword final. This means that the property can only be set once and cannot be changed.

There are two ways to make a property immutable in Dart, by making it a final or a const.

In both cases, if we try to change the value of the property, we’ll get an error.

The difference is that a const needs to be set at compile-time, while a final can be set at a later point. So a const is implicitly a final that needs to be set at compile-time.

A good use for const, is to set some constants at the top of the code, so you can reuse them inside the code instead of hard-coding them at different places.

The GestureDetector widget and passing functions as parameters

Say we wanted to highlight our RowCard when it’s interacted with. We could place it inside a button, but then we’d have to deal with all the specific button functionality and styling. What if we just wanted to detect the gestures and nothing else?

In that case we can wrap it inside a GestureDetector. This has the added benefit that we can detect any kind of gestures, like double tap, long press, drag, etc. Each kind of gesture has its own callback function that we can tap into (nice pun), in order to specify different actions.

So let’s implement a layout where if we tap on a certain card, it’s highlighted.

Expanded widgets must be placed directly inside Flex widgets, so we need to place the GestureDetector between Expanded and Container.

So RowCard looks like this:

class RowCard extends StatelessWidget {
final bool isFirstRow;
final Color colour;
final Function onTapCallback;
RowCard({this.isFirstRow = false, this.colour, this.onTapCallback}); @override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
child: Container(
margin: EdgeInsets.fromLTRB(8.0, isFirstRow ? 8.0 : 0.0, 8.0, 8.0),
decoration: BoxDecoration(
color: colour,
borderRadius: BorderRadius.circular(10.0),
),
),
onTap: onTapCallback,
),
);
}
}

You’ll notice that I added a final Function onTapCallback; property.

This is because in Dart we can pass function like parameters. So in the constructor we can also set the value of the Function onTapCallback.

RowCard({this.isFirstRow = false, this.colour, this.onTapCallback});

and then execute the function inside the onTap callback method of the GestureDetector.

onTap: onTapCallback,

Then _InputPageState looks like this:

final deselectedCardColour = Colors.lightGreen[500];
final selectedCardColour = Colors.lightGreen[700];
class InputPage extends StatefulWidget {
@override
_InputPageState createState() => _InputPageState();
}
class _InputPageState extends State<InputPage> {
int selectedCardIndex;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Rick\'s Protein Intake Calculator'),
),
body: SafeArea(
child: Column(
children: <Widget>[
RowCard(
isFirstRow: true,
colour: selectedCardIndex == 0
? selectedCardColour
: deselectedCardColour,

onTapCallback: () {
setState(() {
selectedCardIndex = 0;
});
},

),
...
(and repeat for rows 1, 2 and 3)

Ok, so from top to bottom:

  • I added two finals at the top of the input_page.dart file to hold the colours:
final deselectedCardColour = Colors.lightGreen[500];
final selectedCardColour = Colors.lightGreen[700];

They need to be final, because they depend on Colors to be set, so they can’t be const, which is set at compile-time.

  • I added an int selectedCardIndex; that will hold the index of the currently selected card.
  • The colour of each card will now be set according to this ternary operator statement:
selectedCardIndex == 0 ? selectedCardColour : deselectedCardColour

So if the index of the card matches the selected card index it will get the selected card colour, otherwise it will get the deselected card colour.

  • We specify the onTapCallback function when we create each RowCard.
onTapCallback: () {
setState(() {
selectedCardIndex = 0;
});
},

So when a card is tapped, we set selectedCardIndex inside setState and this triggers a rebuild that will update all the colours.

Selecting a card highlights it

So now when we tap on a card it gets highlighted and all the other cards get unhighlighted.

Populating the cards

I’m going to start with the first two cards because they are similar.

First of all, I’m going to remove all the highlighting functionality from the previous sections because we don’t need it here anymore, and I’m going to add a child property so our RowCard can have some on-demand content.

This is what the RowCard file looks like now.

class RowCard extends StatelessWidget {
final bool isFirstRow;
final Widget child;
RowCard({
this.isFirstRow = false,
this.child,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
child: child,
margin: EdgeInsets.fromLTRB(8.0, isFirstRow ? 8.0 : 0.0, 8.0, 8.0),
decoration: BoxDecoration(
color: kColourRowCard,
borderRadius: BorderRadius.circular(10.0),
),
),
);
}
}

Then I’m going to create a constants.dart file where I’m going to keep all the constants used throughout this app. This is very good practice.

import 'package:flutter/material.dart';final kColourPrimary = Colors.red[800];
final kColourBackground = Colors.lightGreen[200];
final kColourRowCard = Colors.lightGreen[500];
final kColourSliderActive = kColourPrimary;
final kColourSliderInactive = Colors.lightGreen[700];
const kTextStyleLabel = TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w700,
color: Colors.black87,
);
const kTextStyleValue = TextStyle(
fontSize: 50.0,
fontWeight: FontWeight.w900,
color: Colors.black87,
);
const kIconSize = 50.0;
final kIconColour = Colors.black87;
const kInitialHeight = 170;
const kMinimumHeight = 0;
const kMaximumHeight = 300;
const kInitialWeight = 70;
const kMinimumWeight = 0;
const kMaximumWeight = 700;

This is my constants file after completing this section.

Then I’m going to install the font_awesome_flutter package which contains the Font Awesome Icon pack as a set of Flutter Icons.

The installation is straightforward. Just add it under dependencies in pubspec.yaml.

dependencies:
font_awesome_flutter: ^8.7.0

Then run get packages and stop and run the app again to make sure it’s properly loaded.

I added two properties to _InputPageState that will hold the height and the weight and I initialised them to the constants for the initial height and weight.

class _InputPageState extends State<InputPage> {
int height = kInitialHeight;
int weight = kInitialWeight;

Then I created this structure which is a bit complex, but bear with me.

RowCard(
isFirstRow: true,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(...),
Column(
children: <Widget>[
Text('HEIGHT', style: kTextStyleLabel),
Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: <Widget>[
Text(height.toString(), style: kTextStyleValue),
Text('cm', style: kTextStyleLabel),
],
),
],
),
IconButton(...),
],
),
Slider(...),
],
),
),

To get a visual idea, this creates the following UI:

A control combination of a slider for big movements and buttons for fine-tuning!

The IconButton looks like this:

IconButton(
icon: FaIcon(FontAwesomeIcons.minusCircle),
iconSize: kIconSize,
padding: EdgeInsets.only(left: 30.0),
color: kIconColour,
onPressed: () {
setState(() {
height > kMinimumHeight
? height -= 1
: height = kMinimumHeight;
});
},
),

I used a combination of MainAxisAlignment.spaceBetween for its parent Row and a padding of EdgeInsets.only(left: 30.0) for the left icon and EdgeInsets.only(right: 30.0) for the right icon so I can align the icons horizontally across different cards. This has the added benefit that they don’t wiggle when I move the slider.

In the onPressed callback, I use the ternary operator again (it’s starting to become my favourite!) to impose the minimum and maximum limits on the value of the height.

And respectively for the right button:

onPressed: () {
setState(() {
height < kMaximumHeight
? height += 1
: height = kMaximumHeight;
});
},

Moving on to the label, we need to specify the textBaseline property so that the CrossAxisAlignment.baseline works.

textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,

Finally, for the Slider, we set the min and max values, the current value, the active and inactive colours, as well as the onChanged callback.

Slider(
min: kMinimumHeight.toDouble(),
max: kMaximumHeight.toDouble(),
value: height.toDouble(),
onChanged: (double newValue) {
setState(() {
height = newValue.toInt();
});
},
activeColor: kColourSliderActive,
inactiveColor: kColourSliderInactive,
),

Rinse and repeat for the second card, we have:

You can now see the alignment between the buttons in the two different cards.

If we just used MainAxisAlignment.spaceEvenly or MainAxisAlignment.spaceAround they wouldn’t be aligned and they would wiggle when we moved the slider.

Refactoring the code with a new custom SliderCard widget

Our first two cards have almost identical functionality so they present a great opportunity to create a new custom widget.

The interface and constructor look like this:

class SliderCard extends StatelessWidget {
final bool isFirstRow;
final String labelName;
final int labelValue;
final String labelUnits;
final double sliderMinimumValue;
final double sliderMaximumValue;
final Function onMinusButtonPressed;
final Function onPlusButtonPressed;
final Function onSliderChanged;
SliderCard({
this.isFirstRow = false,
@required this.labelName,
@required this.labelValue,
@required this.labelUnits,
@required this.sliderMinimumValue,
@required this.sliderMaximumValue,
@required this.onMinusButtonPressed,
@required this.onPlusButtonPressed,
@required this.onSliderChanged,
});

In the build method we basically copy and paste the RowCard with all its code from input_page, replacing the specific height/weight values with the properties from our new interface above.

Then, the input_page becomes much more simplified:

body: SafeArea(
child: Column(
children: <Widget>[
SliderCard(
isFirstRow: true,
labelName: 'HEIGHT',
labelValue: height,
labelUnits: 'cm',
sliderMinimumValue: kMinimumHeight.toDouble(),
sliderMaximumValue: kMaximumHeight.toDouble(),
onMinusButtonPressed: () {
setState(() {
height > kMinimumHeight
? height -= 1
: height = kMinimumHeight;
});
},
onPlusButtonPressed: () {
setState(() {
height < kMaximumHeight
? height += 1
: height = kMaximumHeight;
});
},
onSliderChanged: (double newValue) {
setState(() {
height = newValue.toInt();
});
},
),
// ... and so on for the WEIGHT SliderCard

Going beyond widget properties to customise a widget

Using ThemeData

If we want to modify certain widget features that are not available as properties, we might be able to modify them using themes.

For example, say we wanted to make the slider track or thumb bigger.

We could wrap the Slider inside a SliderTheme, which takes as the data property a SliderThemeData widget.

Inside SliderThemeData we can find all sorts of properties such as the trackHeight and the thumbShape that we wanted.

SliderTheme(
data: SliderThemeData(
trackHeight: 3.0,
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 12.0),
),
child: Slider(...),
)

I won’t be using it in my app however, because I like it as it is :)

We can go through the docs for the ThemeData class to find all the different themes that we can use for further customisation for all sorts of different widgets.

From “scratch”

If ThemeData is not enough we can even build a Flutter widget from scratch. By cmd + clicking on a widget, we can see its code and what other widgets it’s based on. We can choose how deep we want to go for our starting widget and then we can build it up with the components that we want.

Finishing up the Input Screen

For the next two cards, we need to make a selection between discreet values:

  • Activity Level: Active or Sedentary
  • Fitness Goal: Muscle Gain, Fat Loss or Maintenance

For this purpose I made two new widgets: WrapCard and WrapCardButton.

I’ll take it from top to bottom for the first card.

To begin with I defined some new variables that will hold the names of the buttons and the states (selected vs. deselected).

List<String> activityLevelNames = ['Active', 'Sedentary'];
List<bool> activityLevelStates = [false, false];

After we’ve done all the work of building the widget, implementing it is very easy:

WrapCard(
labelName: 'ACTIVITY LEVEL',
buttonNames: activityLevelNames,
buttonStates: activityLevelStates,
onButtonPressed: (int pressedButtonIndex) {
setState(() {
for (int i = 0; i < activityLevelStates.length; i++) {
i == pressedButtonIndex
? activityLevelStates[i] = true
: activityLevelStates[i] = false;
}
});
},
),

We’re setting the card label, button names and states, and in onButtonPressed we’re getting the index of the pressed button. Then we iterate through the states, updating them accordingly inside setState, so that when a button is selected, all other buttons are deselected.

Let’s dig in a bit further now.

This is what the WrapCard widget looks like (skipping the interface since it’s similar to the above).

Widget build(BuildContext context) {
return RowCard(
isFirstRow: isFirstRow,
child: Padding(
padding: EdgeInsets.fromLTRB(12.0, 12.0, 12.0, 15.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
labelName,
style: kTextStyleLabel,
textAlign: TextAlign.center,
),
SizedBox(height: 10.0),
Wrap(
alignment: WrapAlignment.center,
spacing: 12.0,
runSpacing: 12.0,
children: layoutButtons(),
),
],
),
),
);
}

It starts with the RowCard widget that we built earlier. For a child, we’re passing it a Column wrapped inside a Padding. The Column includes a Text for the label, a SizedBox for some spacing a Wrap widget.

The Wrap widget is a handy widget that I found browsing through the widget catalog. It lays out widget in a row (or column) and when it runs out of space it wraps them into a next run (row/column). It also has various properties for customisation such as alignment, spacing, runSpacing, etc.

To set the children of the Wrap widget dynamically, I’m running the layoutButtons() method:

List<Widget> layoutButtons() {  List<Widget> children = [];  for (int i = 0; i < buttonNames.length; i++) {
children.add(WrapCardButton(
buttonIndex: i,
buttonLabel: buttonNames[i],
isSelected: buttonStates[i],
onPressed: onButtonPressed,
));
}
return children;
}

I start with an empty children list, then I iterate through the buttonNames and buttonStates lists, creating and populating a new WrapCardButton widget for each element in the lists and I add the WrapCardButton to the children list.

The WrapCardButton widget looks like this.

Widget build(BuildContext context) {
return FlatButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
child: Text(buttonLabel, style: kTextStyleWrapCardButton),
onPressed: () {
onPressed(buttonIndex);
},
color: isSelected
? kColourWrapCardButtonSelected
: kColourWrapCardButtonDeselected,
textColor: isSelected
? kColourWrapCardButtonTextSelected
: kColourWrapCardButtonTextDeselected,
padding: EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 20.0,
),
);
}

It’s a FlatButton with the following properties:

  • shape is set to achieve more rounded borders
  • the child is set to the button label
  • in onPressed we’re passing the buttonIndex that will be propagated all the way up to input_page to determine which button was pressed
  • the color and textColor are set according to whether the button isSelected or not

And that produced the following card:

Initial state
With button selected

It seemed like a lot of work, but following this good practice, we can easily reuse the widgets that we build to create the new card in a matter of seconds!

All we need to do is this:

  • Set the new variables:
List<String> fitnessGoalNames = ['Muscle Gain', 'Fat Loss', 'Maintenance'];
List<bool> fitnessGoalStates = [false, false, false];
  • Add another WrapCard widget:
WrapCard(
labelName: 'FITNESS GOAL',
buttonNames: fitnessGoalNames,
buttonStates: fitnessGoalStates,
onButtonPressed: (int pressedButtonIndex) {
setState(() {
for (int i = 0; i < fitnessGoalStates.length; i++) {
i == pressedButtonIndex
? fitnessGoalStates[i] = true
: fitnessGoalStates[i] = false;
}
});
},
),

and we get this out of the box!

Two WrapCard widgets!

The last thing to do to complete the first screen is to implement the Calculate button:

Container(
margin: EdgeInsets.symmetric(vertical: 0.0, horizontal: 8.0),
width: double.infinity,
child: FlatButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
child: Text('Calculate', style: kTextStyleWrapCardButton),
onPressed: () {},
color: kColourWrapCardButtonSelected,
textColor: kColourWrapCardButtonTextSelected,
padding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 20.0),
),
),

and we have the complete first screen:

I think it looks awesome!

Navigation in Flutter

Now that we want to move to a second screen, we need a way to navigate and to pass data between the two screens.

There are various navigation recipes in the cookbook, but the one we need is navigate to a new screen and back.

An important piece of terminology is that in Flutter, screens and pages are called routes.

Navigating routes works like a stack. We push one route on top of the route stack to make it visible, and then we pop it off the stack to show the previous route.

Pushing and popping is done using the Navigator.push() and Navigator.pop() methods.

Navigating to a new route

Let’s say we have a FirstRoute widget that has a button that we want to press to move to the SecondRoute widget.

Inside that button’s onPressed method, we call Navigator.push().

onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondRoute()),
);
}

We need to pass a BuildContext, which is a handle to the location of a widget in the widget tree.

The build method of our widget kindly provides us with this context:

@override
Widget build(BuildContext context) {
return ...
}

And we also need to pass a Route.

We can create our own Route, or we can use the MaterialPageRoute class, which is useful because it transitions to the new route using a platform-specific animation (i.e. different for Android, iOS, etc).

The MaterialPageRoute constructor requires a WidgetBuilder, which is, as implied by the name, a function that creates a widget. That widget is the widget we want to navigate to.

The function takes the context as input and returns the widget that we want to navigate to.

We can use the regular function notation:

MaterialPageRoute(
builder: (context) {
return SecondRoute();
},
),

or the shorthand:

MaterialPageRoute(builder: (context) => SecondRoute())

Navigating back to the first route

Navigating back to the first route is even easier.

Let’s say we have a button inside SecondRoute that we press to go back to FirstRoute. All we need to do is call Navigator.pop() inside the onPressed callback and pass it the context from our SecondRoute widget.

onPressed: () {
Navigator.pop(context);
}

Navigating with named routes

If we have an app with many routes and more complex navigation, we might get a bit tangled using the above method.

Instead we can use named routes.

When we create the MaterialApp widget, we can specify the routes property and pass it a Map of our routes.

MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => FirstScreen(),
'/second': (context) => SecondScreen(),
},
);

The routes property takes a Map, which is like a dictionary from other languages, essentially a collection of key/value pairs.

For the keys, we specify the route name: ‘/’, ‘/second’, ‘/third’ and so on (or whatever names we want to have).

For the values, we specify the widget that corresponds to each route.

There’s also the initialRoute property that we can set to the route we want to start with when the app initialises.

The initialRoute property replaces the home property. We can’t have both of them at the same time.

Navigating then becomes easier. We just need to call the Navigator.pushNamed() method and pass it the current context and the route name.

onPressed: () {
Navigator.pushNamed(context, '/second');
}

Adding the ResultsPage widget

In the onPressed callback of the Calculate button that we created before, we add:

onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ResultsPage(),
),
);
},

We create a new ResultsPage StatelessWidget in a new results_page.dart file and we import it on top of our input_page.dart file.

class ResultsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(kAppBarMainTitle),
),
body: Container(),
);
}
}

I also replaced the AppBar title with the kAppBarMainTitle constant since I’m using it in two screen now.

Now, if we tap on the Calculate button we navigate to the new screen.

What’s more, we get a free back button on iOS that takes us back to the previous screen, without adding anything or implementing Navigator.pop(), since this is default iOS behaviour.

Tapping on the back button takes us back to the first screen

Also notice that we didn’t need to set any colours for the ResultsPage as we set the theme property of the MaterialApp back in main.dart.

Designing the Results Screen

I replaced the Container we had above with this Column:

body: Column(
children: <Widget>[
ResultCard(
isFirstRow: true,
labelName: 'Your optimal protein intake is at least',
labelValue: 98,
labelUnits: 'grams/day',
),
ResultCard(
labelName: 'Intakes of up to',
labelValue: 168,
labelUnits: 'grams/day',
additionalText: 'may provide additional benefit, based on limited evidence.',
),
ResultCard(
labelName: 'For experienced lifters, intakes up to',
labelValue: 231,
labelUnits: 'grams/day',
additionalText: 'may help minimize fat gain while bulking.',
),
BottomButton(
label: 'Recalculate',
onPressed: () {
Navigator.pop(context);
},
),
],
),

There are two new widgets here, ResultCard and BottomButton.

The BottomButton is simply the Calculate button we had before, refactored into a new widget so we can easily reuse it.

The ResultCard’s build method looks like this:

return RowCard(
isFirstRow: isFirstRow,
child: Padding(
padding: EdgeInsets.fromLTRB(15.0, 12.0, 15.0, 15.0),
child: Column(
children: <Widget>[
Text(
labelName,
style: kTextStyleText,
textAlign: TextAlign.center,
),
Text(
labelValue.toString(),
style: kTextStyleValue,
),
Text(
labelUnits,
style: kTextStyleLabel,
),
if (additionalText != null)
Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
additionalText,
style: kTextStyleText,
textAlign: TextAlign.center,
),
)
],
),
),
);

We’re making use again of our RowCard with a Padding and Column inside.

The Column has three Text widgets and an optional fourth widget.

We can actually include an if statement inside the widget tree to optionally include a widget.

So in this case, if additionalText is not null (i.e. we have set the additionalText property), then the fourth Text widget appears.

This is the results screen, which looks nice and consistent with the input screen :)

Implementing the Calculator Algorithm

All the calculator logic will live in a new file, in new class called CalculatorModel.

You can see the complete implementation here.

It’s a pretty complex file to go through and there’s nothing really new except for some key points that I’ll cover here.

Using enums

enum ActivityLevel { Active, Sedentary }
enum FitnessGoal { MuscleGain, FatLoss, Maintenance }

Enums is short for enumerated types, which is a way for declaring a new type, that has a fixed number of constant values.

For example, the ActivityLevel type can only be ActivityLevel.Active or ActivityLevel.Sedentary.

Inside the calculate() method, I’m calling parseActivityLevel() and parseFitnessGoal() in order to get the type that was selected by the user.

void calculate() {
ActivityLevel activityLevel = parseActivityLevel();
FitnessGoal fitnessGoal = parseFitnessGoal();

If you remember, we had these properties in InputPage, that I now moved to the CalculatorMode:

List<String> activityLevelNames = ['Active', 'Sedentary'];
List<bool> activityLevelStates = [true, false];
List<String> fitnessGoalNames = ['Muscle Gain', 'Fat Loss', 'Maintenance'];
List<bool> fitnessGoalStates = [true, false, false];

The parsing methods look like this:

ActivityLevel parseActivityLevel() {
return ActivityLevel.values[activityLevelStates.indexOf(true)];
}
FitnessGoal parseFitnessGoal() {
return FitnessGoal.values[fitnessGoalStates.indexOf(true)];
}

I’m calling activityLevelStates.indexOf(true) to find the index of the true element in the states array, which corresponds to the button that was selected.

The values method returns a list of all the possible enum values, so we can use that index in that list to get the selected enum type.

The next thing to do is to calculate the BMI in order to get the isHealthyWeight() result.

bool isHealthyWeight() {
double bmi = weight.toDouble()*10000/(height*height).toDouble();
bmi = double.parse(bmi.toStringAsFixed(1));
return bmi >= 25.0 ? false : true;
}

The first line calculates the BMI.

The second line rounds it to one decimal place (that’s the most elegant solution currently in Dart).

The third line returns the interpretation of the BMI.

Then what follows is a lengthly triple-level switch statement that digs through all the possibilities of the algorithm in order to set these outputs:

// OUTPUTS
int minimumProteinIntake;
int maximumProteinIntake;
int powerLifterProteinIntake;
String minimumResultLabel;
String maximumResultLabel;
String maximumResultAdditionalLabel;
String additionalInfoLabel;
String additionalInfoAdditionalLabel;
String units = 'grams/day';

And that’s pretty much the calculator model.

Back in InputPage, I replaced the instances of the inputs that we had with an instance of CalculatorModel.

CalculatorModel model = CalculatorModel();

and replaced all the relevant part of the code like model.height instead of height, etc.

In the onPressed callback of the Calculate button I call model.calculate() and then I pass the model to the ResultsPage.

onPressed: () {
model.calculate();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ResultsPage(model: model),
),
);
},

The last thing to do is replace all the properties in ResultsPage with references to the model.

Also, since many of the outputs can be null, I included the appropriate if statements in the widget tree to optionally include certain widgets.

if (model.maximumProteinIntake != null)
ResultCard(...)
...
and so on...

For example, we might be missing a whole card if we don’t have a maximum protein intake or any additional info, or within the card we might not have any numbers.

This way we can have all these different results screens that are adaptable to the outputs 😁

The final code for Episode 6 is here.

If you made it so far, you’re awesome! 🥳🥳🥳

ALERT: Next episodes are out!

--

--