P1 — From React to Flutter Web

Hello everyone,
I was thinking about it a lot and here we go. I’ll try to share some of my practices of moving from React to Flutter Web. Single one pager website.

Bare in mind that as for this point (9/10/2019) Flutter Web is still in very early state and not suitable for production.

Let’s begin.

Current Look

You can see, how it currently looks while it’s written in React.

Web look and feel

Mobile look and feel

Future Look

After all parts, you can see result, of how RentMi will look like fully migrated in Flutter. Pardon for the lag — while rendering images on Flutter Web it causes some delay. Technical preview, guys!

Web look and feel

Mobile look and feel

Let’s begin.

Introduction

Website currently is written by my friend in React. And since I myself am in Mobile space for many years, I decided to learn Flutter instead of React.

Fast Forward many months after, I decided to rewrite whole web in Flutter. As we have several routes and currently it’s a mix between React and Flutter, I decided to unite everything under one umbrella. Because now it’s quite a chaos in our Nginx (😅).

As I mentioned earlier, Flutter is in early tech. preview stage but nevertheless, I decided to go for it.

As you noticed from video already, RentMi is split in various different sections. Some of them are similar, some are different.

I’ll split my articles in many different parts as it would be easier for you to consume it. In this one we will focus on AppBar and the Top Part of the website (very first section (header?)).

Whole App structure

I tend to make all pages as Stateful Widget. I’m huge fan of Provider and MVP pattern therefore Stateful Widget serves the best for me due to initState() method.

class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: RentMiAppBar(
isBackButtonEnabled: false,
),
backgroundColor: Colors.white,
body: _MainSection()
);
}
}

As you can see above, we will have Stateful Widget (as I mentioned before) and a Scaffold. Scaffold has an appBar field where we can assign our appBar. We assign our custom appBar (I will get into this later).
We also set backgroundColor to white and will have body of _MainSection

And _MainSection widget looks like this:

class _MainSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: EdgeInsets.all(22),
children: <Widget>[
Column(
children: <Widget>[
_SectionOne(),
Container(
transform: Matrix4.translationValues(0.0, -140.0, 0.0),
child: _BenefitsSection(
listData: {..}
),
),
_BenefitsSection(
isMirrored: true,
listData: {..},
),
_SectionThree(),
_BenefitsSection(
listData: {..}
),
_LanguageSection(),
_AboutSection(),
_PeopleSection(),
_FooterSection()
],
),
],
);
}
}

As I mentioned before, we split RentMi website in various sections therefore each section is a Widget over here.
Section widgets are put in ListView which allows us to scroll vertically once content doesn’t fit vertically within the screen.

AppBar

Mobile
Web

I love customizing things. I had this bad habit coming all over from native Android development. Especially in the beginning, when you learn a new language, you don’t know what you don’t know. Which may lead into creating crazy Widgets while they already exist. You just didn’t discover them yet 😆

Similar situation happened with AppBar.

class RentMiAppBar extends StatelessWidget implements PreferredSizeWidget {
final bool isBackButtonEnabled;

const RentMiAppBar({Key key, this.isBackButtonEnabled = true}) : super(key: key);

@override
Widget build(BuildContext context) {
return AppBar(
iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
automaticallyImplyLeading: isBackButtonEnabled,
backgroundColor: Colors.white,
title: InkWell(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
onTap: () => {
//TODO handle tap
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
_getRentMiLogo(context),
height: 35,
),
),
),
actions: <Widget>[_getRightButtons(context)],
);
}

String _getRentMiLogo(context) {
var size = MediaQuery.of(context).size;
if (size.width < ScreenSizeHelper.SCREEN_WIDTH_SMALL) {
return "assets/images/rentmi_logo.png";
} else {
return "assets/images/rentmi_logo_with_text.png";
}
}

Widget _getRightButtons(context) {
final _userStore = Provider.of<UserStore>(context);
if (_userStore.isLoggedIn()) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: RentMiButton(
color: RentMiColors.kRentMiRed.value, text: "Log-Out", onButtonClickListener: onLogOutClick));
} else {
return Row(
children: <Widget>[
RentMiButton(
color: RentMiColors.kRentMiRed.value,
text: "Get RentMi App",
onButtonClickListener: () => onRentMiButtonClick(context))
],
);
}
}

void onRentMiButtonClick(BuildContext context) {
Common.showDownloadRentMiAppDialog(context, 'Explore more on RentMi. Get app now.');
}

void onLogOutClick() {
print('logout');
}

@override
Size get preferredSize => Size.fromHeight(kToolbarHeight);
}

We are creating our custom AppBar. All we need is to create a Stateless Widget which implements PreferredSizeWidget. Afterwards we just override preferredSize method with default height of the toolbar (I love to be consistent).

@override
Size get preferredSize => Size.fromHeight(kToolbarHeight);

AppBar by default has leading icon (back button). I want our appBar to be able to toggle it therefore we have a boolean field of isBackButtonEnabled.
If it’s true — we show back button, if false — don’t show.

final bool isBackButtonEnabled;

const RentMiAppBar({Key key, this.isBackButtonEnabled = true}) : super(key: key);
@override
Widget build(BuildContext context) {
return AppBar(
..
automaticallyImplyLeading: isBackButtonEnabled,
..
}

We also want to have a custom title. It should be clickable and have an image.

To make Widgets clickable, it’s a good practice to wrap it to InkWell widget. Which also provides a splash animation.

onTap we can implement whatever we want to happen, once that widget is tapped.

title: InkWell(
hoverColor: Colors.transparent,
splashColor: Colors.transparent,
onTap: () => {
//TODO handle tap
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
_getRentMiLogo(context),
height: 35,
),
),
),

As a child, we have simple Image asset. Here I use separate method _getRentMiLogo(context) for getting an image. Why you ask?

String _getRentMiLogo(context) {
var size = MediaQuery.of(context).size;
if (size.width < ScreenSizeHelper.SCREEN_WIDTH_SMALL) {
return "assets/images/rentmi_logo.png";
} else {
return "assets/images/rentmi_logo_with_text.png";
}
}

Can you spot why? 😏

Since we are developing for Web, we need to take into considerations various screen sized. Most importantly large and small ones.
To retrieve screenSize, flutter has a MediaQuery widget. From the result, we can identify how large is our screen and based on that, display different widgets.

I use

static final int SCREEN_WIDTH_SMALL = 500;

So if screen is less than 500px, we will show different widget than if it’s more than 500px.
Here’s result as you’ve seen before:

Width = 355px
Width = 509px

Actions define what will be visible on the right side of the widget. And here I again call separate method to get a button widget (_getRightButtons(context))

Widget _getRightButtons(context) {
final _userStore = Provider.of<UserStore>(context);
if (_userStore.isLoggedIn()) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: RentMiButton(
color: RentMiColors.kRentMiRed.value, text: "Log-Out", onButtonClickListener: onLogOutClick));
} else {
return Row(
children: <Widget>[
RentMiButton(
color: RentMiColors.kRentMiRed.value,
text: "Get RentMi App",
onButtonClickListener: () => onRentMiButtonClick(context))
],
);
}
}

Here I pretty much check, if user is logged in. It returns me a boolean value true or false. Based on that, I display different buttons with different click actions.
And again, custom widgets! 🤪

class RentMiButton extends StatelessWidget {
final int color;
final String logo;
final String text;
final VoidCallback onButtonClickListener;

const RentMiButton({Key key, this.color, this.logo = "", this.text, this.onButtonClickListener}) : super(key: key);

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: RaisedButton(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
color: Color(color),
child: Row(
children: <Widget>[
if (logo.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
logo,
height: 15,
),
),
Text(text, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold))
],
),
onPressed: onButtonClickListener,
),
);
}
}

RentMiButton is a stateless widget. Long story short it’s just a regular RaisedButton with rounded corners and based on parameters passed, having an icon or not.
I wanted to create such button so I could reuse it everywhere I need. Less boilerplate code -> happier developer.

If we get back, there is a line

color: RentMiColors.kRentMiRed.value

Here again, for Styles, colors etc which are reused, I create my own classes. In this case, RentMiColors dart class which consists of RentMi color. Less boilerplate code -> happier developer.

class RentMiColors {
...
static const Color kRentMiRed = Color(0xFFFE5a43);
...
}

One last stop is what happens when people click AppBar action widget.

void onRentMiButtonClick(BuildContext context) {
Common.showDownloadRentMiAppDialog(context, 'Explore more on RentMi. Get app now.');
}

.. custom widget will appear?! 🤓 Not quite. I use Common class only for methods which are repeatedly called within whole application. Less boilerplate code -> happier developer.

static showDownloadRentMiAppDialog(BuildContext context, String text) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20.0))),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Image.asset(
"assets/images/rentmi_doodle.png",
height: 200,
),
Text(text),
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
AppStoreButtonWidget(
buttonColor: RentMiColors.kRentMiRed.value,
logo: "assets/images/ios_logo_white.png",
label: "App Store",
storeUrl: URLs.IOS_APP_STORE),
AppStoreButtonWidget(
buttonColor: 0xFF3C3C3C,
logo: "assets/images/android_logo_white.png",
label: "Google Play",
storeUrl: URLs.ANDROID_PLAY_STORE)
],
)
],
),
),
);
});
}

It simply opens our beloved Dialog. We customize it’s shape so it would look more user friendly with more rounded corners.

shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20.0)))

As a base it has a Column widget which consists of simple image and two buttons — which are custom 😅.

Reason behind it is again, mostly different screen sizes. Because mobile screen can be really small.

class AppStoreButtonWidget extends StatelessWidget {
final int buttonColor;
final String logo;
final String label;
final String storeUrl;

const AppStoreButtonWidget(
{Key key,
this.buttonColor,
this.logo,
this.label,
this.storeUrl})
: super(key: key);

String _getLabel(context, label) {
var size = MediaQuery.of(context).size;
if (size.width < common.ScreenSizeHelper.SCREEN_WIDTH_SMALL) {
if (label == "App Store") {
return "iOS";
} else {
return "Android";
}
} else {
return label;
}
}

onAppStoreButtonClick(){
common.Common.openWebsite(storeUrl);
}

@override
Widget build(BuildContext context) {
String label = _getLabel(context, this.label);
return RentMiButton(color: buttonColor, logo: logo, text: label, onButtonClickListener: onAppStoreButtonClick,);
}
}

Here we again check for different screen sizes. But in this scenario, we will return different labels.

385px
500+ px

Aaaand we are done! With AppBar. 🤪

_SectionOne

And now it’s the time to start with sections. First section — section one.

Mobile

Code:

class _SectionOne extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (MediaQuery.of(context).size.width < ScreenSizeHelper.SCREEN_WIDTH_MEDIUM) {
return Column(
children: <Widget>[_getImage(), _getInfo()],
);
} else {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(child: _getInfo()),
),
Container(
width: 25,
),
Expanded(
child: Container(constraints: BoxConstraints(maxHeight: 500), child: _getImage()),
),
],
),
);
}
}

Widget _getInfo() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Flexible(child: AutoSizeText("Real ", style: Styles.kSectionMainTitle)),
Flexible(
child: RotateAnimatedTextKit(
duration: Duration(seconds: 10),
text: [
"People",
"Homes",
],
transitionHeight: Styles.kSectionMainTitle.fontSize,
textStyle: Styles.kSectionMainTitleColored,
textAlign: TextAlign.start,
alignment: AlignmentDirectional.topStart // or Alignment.topLeft
),
),
],
),
AutoSizeText(
"Find your dream home and roommate effortlessly.\n"
"Download the APP to start!",
maxLines: 2,
style: Styles.kSectionMainSubtitle,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
AppStoreButtonWidget(
buttonColor: RentMiColors.kRentMiRed.value,
logo: "assets/images/ios_logo_white.png",
label: "App Store",
storeUrl: URLs.IOS_APP_STORE),
AppStoreButtonWidget(
buttonColor: 0xFF3C3C3C,
logo: "assets/images/android_logo_white.png",
label: "Google Play",
storeUrl: URLs.ANDROID_PLAY_STORE)
],
),
Padding(
padding: const EdgeInsets.only(top: 55.0),
child: Container(
transform: Matrix4.translationValues(-35.0, 0, 0.0),
child: Image.asset(
"assets/images/rentmi_doodle_two_tr.png",
height: 100,
),
),
),
],
);
}

Widget _getImage() {
return Image.asset(
"assets/images/rentmi_doodle_tr.png",
);
}
}

We start with Stateless widget and immediately checking screen size. We start scaling after screenSize hits less than

ScreenSizeHelper.SCREEN_WIDTH_MEDIUM

Which is 800px.

If it’s less than 800px — we simply lay our widgets vertically (Column).
If it’s more than 800px — opposite — we are using Row and have our widgets laying horizontally.

As you noticed, I’m using couple methods to get my widgets — _getImage(), and _getInfo(). Sole purpose for that is : less boilerplate code -> happier developer. We try to reuse our code.

_getImage() shows us an image. Couldn’t be more straight.
_getInfo() — oh my. See image for explanation — I believe it will be more clear.

Maze

We keep our structure vertical (Column). Then we lay our other children accordingly — Row, AutoSizeText, Row and Padding. Some of these has other children too.

What might be interesting, here we see again AppStoreButtonWidget. If you check back and see SectionOne mobile and web images — smaller screen has less text on the button. And we don’t need to create new widget for it since we already have it! Less boilerplate code -> happier developer

And we are done.
Final result of AppBar and first section:

Mobile
Web

I hope you enjoyed it. Let me know if you would love to experience audio/video version of walkthrough instead of written. And please ask whether you have any questions!

Less boilerplate code -> happier developer count — 5.

I build kick-ass mobile apps @ https://isawthatguy.com || Product Virtuoso and Startup Freak

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store