Building a Multi-Platform Travel App With Flutter [With Code Samples]
By: Vitaly Kuprenko
Tags: Flutter, Flutter App, App Development, Flutter App Development, Flutter Ui

Building a Multi-Platform Travel App With Flutter [With Code Samples]

Apps that have a single codebase but run on a few platforms is one of the hottest trends in 2020. Github, in their latest ‘The State of the Octoverse’ report, includes Flutter to the three most trending open source projects (13k contributors).

Easy to guess why: technologies like Flutter UI framework are time-savers for devs and budget-cutters for owners.

In this guide, we’re going to show you how to build a simple travel app with Flutter. If you want to see the outcome right now, check the app on Google Play.

Here’s what you’ll see in this tutorial, by screens:

MyApp

Splash screen

StartPage

Facebook authorization

Sign In/Sign Up

Complete profile

Profile

List of countries

Search by countries

Detail page

Add comment

One more thing: this guide is mostly for devs that already have some experience with Flutter, Firebase, and know UI design principles.

Now, it’s coding time.

MyApp

First, orientation. Mind that the app will support the portrait mode only:



@override

Widget build(BuildContext context) {

// only portrait orientation

SystemChrome.setPreferredOrientations([

DeviceOrientation.portraitUp,

DeviceOrientation.portraitDown,

]);

return MaterialApp(…);

}

} class MyApp extends StatelessWidget {Widget build(BuildContext context) {// only portrait orientationSystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp,DeviceOrientation.portraitDown,]);return MaterialApp(…);

Next, we should set up basic widgets styles. That means you create a class with styles, colors, resources, and so on.

TextStyle class:

class FCIStyle {

static TextStyle H4White100Left = const TextStyle(

fontFamily: ‘Metropolis’,

color: Color(0xffffffff),

fontSize: 28,

fontWeight: FontWeight.w700,

fontStyle: FontStyle.normal,

letterSpacing: 0.36,

);

…

}

Same with colors. Here’s how you set styles for most used elements and AppBar:

MaterialApp(

theme: ThemeData(

accentColor: FCIColors.accent,

primaryColor: FCIColors.primary,

textTheme: TextTheme(

subtitle: FCIStyle.Subtitle1Black100Left,

body1: FCIStyle.Body1Black100Center,

body2: FCIStyle.Body2Black100Left,

button: FCIStyle.ButtonWhite100Center,

caption: FCIStyle.CaptionBlack100Center,

title: FCIStyle.H6Black100Center,

),

buttonTheme: ButtonThemeData(

hoverColor: FCIColors.vermillion,

buttonColor: FCIColors.blue_violet,

shape: RoundedRectangleBorder(

borderRadius: BorderRadius.circular(24),

),

textTheme: ButtonTextTheme.primary,

height: 48,

),

appBarTheme: AppBarTheme(

elevation: 0,

textTheme: TextTheme(title: FCIStyle.TitleWhite100Left),

),

),

);

Define the screens of the future app:

initialRoute: SplashPage.routeName,

routes: {

SplashPage.routeName: (context) => SplashPage(),

StartPage.routeName: (context) => StartPage(),

SignInPage.routeName: (context) => SignInPage(),

SignUpPage.routeName: (context) => SignUpPage(),

CompleteProfilePage.routeName: (context) => CompleteProfilePage(),

CountriesPage.routeName: (context) => CountriesPage(),

InfoPage.routeName: (context) => InfoPage(),

RestorePasswordPage.routeName: (context) => RestorePasswordPage(),

ProfilePage.routeName: (context) => ProfilePage(),

CountryDetailPage.routeName: (context) => CountryDetailPage(),

FullPhotoPage.routeName: (context) => FullPhotoPage(),

AddCommentPage.routeName: (context) => AddCommentPage(),

SearchPage.routeName: (context) => SearchPage(),

},

The routeName is a string constant with the route’s name opening widgets.

Splash Screen

To make the process simpler, we’re going to use the flutter_bloc and the BLoC pattern for architecture. If you need more Block use cases, check them on pub.dev.

Now, let’s get down to the splash screen.

Splash screen

class _SplashPage extends State<SplashPage> {

var _bloc = SplashBloc();

var _isUserLogged = false; @override

void initState() {

_bloc.dispatch(IsUserLoggedEvent());

super.initState();

} void initState() {_bloc.dispatch(IsUserLoggedEvent());super.initState(); @override

Widget build(BuildContext context) {

return Scaffold(

body: BlocBuilder(

bloc: _bloc,

builder: (context, state) {

switch (state.runtimeType) {

case IsUserLoggedState:

_isUserLogged = (state as IsUserLoggedState).isUserLogged;

break;

}

return _initState();

},

),

);

} Widget build(BuildContext context) {return Scaffold(body: BlocBuilder(bloc: _bloc,builder: (context, state) {switch (state.runtimeType) {case IsUserLoggedState:_isUserLogged = (state as IsUserLoggedState).isUserLogged;break;return _initState();},),); _initState() => Container(

width: double.infinity,

height: double.infinity,

child: Stack(

children: <Widget>[

Center(

child: Container(

child: Column(

mainAxisSize: MainAxisSize.min,

mainAxisAlignment: MainAxisAlignment.center,

children: <Widget>[

Container(

width: 133,

height: 145,

child: SvgPicture.asset(FCIAssets.logo),

),

Text(

FCIStrings.of(context).travelTime.toUpperCase(),

style: FCIStyle.H5White100Center,

)

],

)),

),

Align(

alignment: Alignment.bottomCenter,

child: Container(

margin: EdgeInsets.only(bottom: 32, left: 50, right: 50),

child: TimerLinearIndicator(onStop: () {

_navigatePage();

}),

),

),

],

),

decoration: BoxDecoration(

gradient: LinearGradient(

begin: Alignment.topCenter,

end: Alignment.bottomCenter,

colors: <Color>[

FCIColors.gradientStart,

FCIColors.gradientEnd,

]),

),

); _navigatePage() {

if (_isUserLogged) {

Navigator.pushReplacementNamed(context, CountriesPage.routeName);

} else {

Navigator.pushReplacementNamed(context, StartPage.routeName);

}

}

}

Who is who:

_initState is the UI that users see. With this method, you’re calling _bloc.dispatch(IsUserLoggedEvent());. It holds the event returning a state to BlocBuilder builder: (context, state).

Depending on the state, you can change the current UI or redirect users to the next screen.

Here’s how the Bloc looks like:

First, it receives event(IsUserLoggedEvent) and checks whether a user is logged in or not. Next, it returns event(IsUserLoggedEvent) for our Splash screen.

class SplashBloc extends BaseBloc {

final userRepository = FirebaseProvider.userRepository; @override

Stream<BaseState> mapEventToState(BaseEvent event) async* {

switch (event.runtimeType) {

case IsUserLoggedEvent:

yield await _isUserLogged();

}

} Stream mapEventToState(BaseEvent event) async* {switch (event.runtimeType) {case IsUserLoggedEvent:yield await _isUserLogged(); _isUserLogged() => userRepository

.isUserLogged()

.then((isUserLogged) => IsUserLoggedState(isUserLogged))

.catchError((e) => handleError(e));

}

Basic BLoC implementation:



@override

BaseState get initialState => InitState(); abstract class BaseBloc extends Bloc {BaseState get initialState => InitState(); ErrorState errorState; handleError(e) {

if (e is PlatformException) {

errorState = ErrorState(msg: e.message);

} else if (...) ...

else {

errorState = ErrorState(msg: e.toString());

}

}

}

Redefine one method and create a function for error-handling. There you can add the types of Exceptions you need and handle them the way you want.

Next, handle State from the SplashBloc (Splash — BlocBuilder — builder method). You get State as a parameter and call the _navigatePage to redirect users to the registration/login screen or the list.

StartPage

How the start page looks in the app:

Start page

This screen redirects to the sing up or login page, plus offers a Facebook login option.

class _StartPage extends State<StartPage> {

final _bloc = StartBloc(); @override

Widget build(BuildContext context) {

return BaseScaffold(

child: BlocBuilder(

bloc: _bloc,

condition: (oldState, currentState) {

if (currentState is FacebookLoggedState) {

if (currentState.user.isCompleteProfile()) {

Navigator.pushNamedAndRemoveUntil(

context, CountriesPage.routeName, (route) => false);

} else {

Navigator.pushNamedAndRemoveUntil(

context, CompleteProfilePage.routeName, (route) => false,

arguments: CompleteModel(false,

isNewFacebookUser: currentState.isLogin));

}

}

return true;

},

builder: (context, state) {

switch (state.runtimeType) {

case ProgressState:

return Center(

child: CircularProgressIndicator(),

);

}

return _initState();

},

));

} Widget build(BuildContext context) {return BaseScaffold(child: BlocBuilder(bloc: _bloc,condition: (oldState, currentState) {if (currentState is FacebookLoggedState) {if (currentState.user.isCompleteProfile()) {Navigator.pushNamedAndRemoveUntil(context, CountriesPage.routeName, (route) => false);} else {Navigator.pushNamedAndRemoveUntil(context, CompleteProfilePage.routeName, (route) => false,arguments: CompleteModel(false,isNewFacebookUser: currentState.isLogin));return true;},builder: (context, state) {switch (state.runtimeType) {case ProgressState:return Center(child: CircularProgressIndicator(),);return _initState();},)); _initState() => Container(

width: double.infinity,

height: double.infinity,

decoration: BoxDecoration(

image: DecorationImage(

fit: BoxFit.fill, image: AssetImage(FCIAssets.bgPhoto)),

),

child: Stack(

children: <Widget>[

Align(

alignment: Alignment.topRight,

child: InkWell(

child: Padding(

padding: const EdgeInsets.only(

right: 30, top: 24, left: 30, bottom: 12),

child: Text(

FCIStrings.of(context).signUp.toUpperCase(),

style: FCIStyle.ButtonWhite100Right,

),

),

onTap: () => Navigator.pushNamed(context, SignUpPage.routeName),

),

),

Align(

alignment: Alignment.bottomCenter,

child: Padding(

padding: const EdgeInsets.only(bottom: 34, left: 32, right: 32),

child: Column(

mainAxisSize: MainAxisSize.min,

children: <Widget>[

Text(

FCIStrings.of(context).travelTime.toUpperCase(),

style: FCIStyle.H5White100Center,

),

SizedBox(

height: 8,

),

Text(

FCIStrings.of(context).startPageDesc,

textAlign: TextAlign.center,

style: FCIStyle.Subtitle1White100Center,

),

SizedBox(

height: 45,

),

Container(

width: double.infinity,

child: OutlineButton(

borderSide: BorderSide(color: FCIColors.whiteBg),

onPressed: () =>

Navigator.pushNamed(context, SignInPage.routeName),

child: Text(

FCIStrings.of(context).login.toUpperCase(),

style: FCIStyle.ButtonWhite100Center,

),

),

),

SizedBox(

height: 16,

),

Container(

width: double.infinity,

child: RaisedButton(

child: AutoSizeText(

FCIStrings.of(context).btnFacebook.toUpperCase(),

style: FCIStyle.ButtonWhite100Center,

maxLines: 1,

),

onPressed: () => _bloc.dispatch(FacebookAuthEvent()),

)),

],

),

),

),

],

),

);

}

In BlocBuilder, you determine the meta condition that returns true or false value. true means calling the builder method, false — not calling. Now you move to a new screen or show an error by displaying it on SnackBar.

Facebook Authorization

Social network integration is quite important these days. For that, we’re going to use the Flutter Facebook Login plugin.

By clicking the Facebook button, we call bloc.dispatch(FacebookAuthEvent()). This code sample tells bloc(StartBloc) that a user wants to log in with their Facebook account.

Let’s look at the code.

class StartBloc extends BaseBloc {

final facebookLogin = FacebookLogin();

final userRepository = FirebaseProvider.userRepository;

final facebookPermissions = ["email", "public_profile"]; @override

Stream<BaseState> mapEventToState(BaseEvent event) async* {

switch (event.runtimeType) {

case FacebookAuthEvent:

yield ProgressState();

yield await _facebookLogin(

await facebookLogin.logIn(facebookPermissions));

break;

}

} Stream mapEventToState(BaseEvent event) async* {switch (event.runtimeType) {case FacebookAuthEvent:yield ProgressState();yield await _facebookLogin(await facebookLogin.logIn(facebookPermissions));break; _facebookLogin(FacebookLoginResult result) async {

switch (result.status) {

case FacebookLoginStatus.loggedIn:

return await _getUserData(result);

case FacebookLoginStatus.cancelledByUser:

return FacebookCanceledState();

case FacebookLoginStatus.error:

return ErrorState(msg: result.errorMessage);

}

} _getUserData(FacebookLoginResult result) => userRepository

.getUserFromFacebook(result.accessToken)

.then((userData) =>

FacebookLoggedState(userData.keys.first, userData.values.first))

.catchError((e) => handleError(e));

}

facebookLogin calls the logIn method receiving the permissions you must get if the user wants to log in via Facebook. In this case, you receive [“email”, “public_profile”].

_facebookLogin method handles all cases after the authorization (e.g. loggedIn, cancelledByUser, error). It depends on FacebookLoginStatus.

Everything fine? Then you get the loggedIn status and FacebookAccessToken accessToken. You send it to Firebase for registration via Facebook.

To do that, set your app up on Facebook and Firebase Auth in advance (email + password and Facebook Auth).

To link the Facebook Token with Firebase, use the following method:

Future<Map<User, bool>> getUserFromFacebook(FacebookAccessToken token) async {

var facebookUser = await _firebaseAuth

.signInWithCredential(

FacebookAuthProvider.getCredential(accessToken: token.token))

.then((authResult) =>

FacebookUser.fromJson(authResult.additionalUserInfo.profile));

if (await isUserExist(facebookUser?.email)) {

return await _facebookSignIn();

} else {

return _facebookSignUp(facebookUser);

}

}

Here’s how the Facebookuser looks:

class FacebookUser {

String id;

String name;

String birthday;

String email;

Picture picture; FacebookUser({this.id, this.name, this.birthday, this.email, this.picture}); FacebookUser.fromJson(Map<String, dynamic> json) {

id = json['id'];

name = json['name'];

birthday = json['birthday'];

email = json['email'];

picture =

json['picture'] != null ? Picture.fromJson(json['picture']) : null;

} Map<String, dynamic> toJson() {

final Map<String, dynamic> data = Map<String, dynamic>();

data['id'] = this.id;

data['name'] = this.name;

data['birthday'] = this.birthday;

data['email'] = this.email;

if (this.picture != null) {

data['picture'] = this.picture.toJson();

}

return data;

}

} class Picture {

Data data; Picture({this.data}); Picture.fromJson(Map<dynamic, dynamic> json) {

data = json['data'] != null ? Data.fromJson(json['data']) : null;

} Map<String, dynamic> toJson() {

final Map<String, dynamic> data = Map<String, dynamic>();

if (this.data != null) {

data['data'] = this.data.toJson();

}

return data;

}

} class Data {

int height;

bool isSilhouette;

String url;

int width; Data({this.height, this.isSilhouette, this.url, this.width}); Data.fromJson(Map<dynamic, dynamic> json) {

height = json['height'];

isSilhouette = json['is_silhouette'];

url = json['url'];

width = json['width'];

} Map<String, dynamic> toJson() {

final Map<String, dynamic> data = Map<String, dynamic>();

data['height'] = this.height;

data['is_silhouette'] = this.isSilhouette;

data['url'] = this.url;

data['width'] = this.width;

return data;

}

}

Then redirect users to the Complete Profile if the profile isn’t complete yet. Or to the main screen if all details are added.

But we’re going to review the Complete Profile screen later on. Now, it’s authorization and registration we’re taking a look at.

Sign In Screen

Check the screen below. Yep, it’s not a default behavior of handling errors.

Sign-in screen

To do this, configure your widget once and then you can reuse it.

Here’s how you implement it:

class FCITextFieldBox extends StatefulWidget {

final String hint;

final String label;

final int maxLines;

final double marginTop;

final TextEditingController controller;

final bool obscureText;

final TextInputType inputType;

final int maxLength; const FCITextFieldBox(

{Key key,

this.hint,

this.label,

this.maxLines = 1,

this.marginTop = 0,

this.controller,

this.obscureText = false,

this.inputType,

this.maxLength})

: super(key: key); @override

State<StatefulWidget> createState() => FCITextFieldBoxState();

} State createState() => FCITextFieldBoxState(); class FCITextFieldBoxState extends State<FCITextFieldBox> {

bool _isValid = true;

String _lastText = "";

bool _isFocused = false; var focusNode = FocusNode(); @override

void initState() {

widget.controller?.addListener(() {

if (_lastText != widget.controller.text && !_isValid) {

setState(() {

_isValid = true;

});

}

_lastText = widget.controller.text;

});

focusNode.addListener(() {

setState(() {

_isFocused = focusNode.hasFocus;

});

});

super.initState();

} void initState() {widget.controller?.addListener(() {if (_lastText != widget.controller.text && !_isValid) {setState(() {_isValid = true;});_lastText = widget.controller.text;});focusNode.addListener(() {setState(() {_isFocused = focusNode.hasFocus;});});super.initState(); @override

Widget build(BuildContext context) {

return Container(

margin: EdgeInsets.only(top: widget.marginTop),

child: TextField(

buildCounter: (BuildContext context,

{int currentLength, int maxLength, bool isFocused}) =>

null,

maxLengthEnforced: true,

maxLength: widget.maxLength,

keyboardType: widget.inputType,

focusNode: focusNode,

obscureText: widget.obscureText,

controller: widget.controller,

style: widget.obscureText

? FCIStyle.PasswordBlack100left

: FCIStyle.TextFieldsBlack100Left,

decoration: InputDecoration(

contentPadding:

EdgeInsets.only(left: 30, bottom: 15, top: 15, right: 30),

enabledBorder: _createBorder(false),

focusedBorder: _createBorder(true),

hintText: widget.hint,

hintStyle: FCIStyle.Body1Black22left,

labelText: widget.label,

errorStyle: FCIStyle.CaptionRed100Right,

labelStyle: _isFocused ? _focusedStyle() : _unFocusedStyle(),

),

maxLines: widget.maxLines,

));

} Widget build(BuildContext context) {return Container(margin: EdgeInsets.only(top: widget.marginTop),child: TextField(buildCounter: (BuildContext context,{int currentLength, int maxLength, bool isFocused}) =>null,maxLengthEnforced: true,maxLength: widget.maxLength,keyboardType: widget.inputType,focusNode: focusNode,obscureText: widget.obscureText,controller: widget.controller,style: widget.obscureText? FCIStyle.PasswordBlack100left: FCIStyle.TextFieldsBlack100Left,decoration: InputDecoration(contentPadding:EdgeInsets.only(left: 30, bottom: 15, top: 15, right: 30),enabledBorder: _createBorder(false),focusedBorder: _createBorder(true),hintText: widget.hint,hintStyle: FCIStyle.Body1Black22left,labelText: widget.label,errorStyle: FCIStyle.CaptionRed100Right,labelStyle: _isFocused ? _focusedStyle() : _unFocusedStyle(),),maxLines: widget.maxLines,)); _createBorder(bool isFocused) => OutlineInputBorder(

borderRadius: BorderRadius.circular(24),

borderSide: BorderSide(

color: isFocused ? _focusedBorderColor() : _unFocusedBorderColor())); _focusedBorderColor() =>

_isValid ? FCIColors.blue_violet : FCIColors.vermillion; _unFocusedBorderColor() =>

_isValid ? FCIColors.black_22 : FCIColors.vermillion; _focusedStyle() =>

_isValid ? FCIStyle.CaptionPurple100Right : FCIStyle.CaptionRed100Right; _unFocusedStyle() =>

_isValid ? FCIStyle.Body1Black22left : FCIStyle.CaptionRed100Right; changeValidator(bool isValid) {

setState(() {

_isValid = isValid;

});

}

}

Set up the widget to let users see in which field they’ve made an error.

Call currentState.changeValidator(false) for that. The incorrect field will be highlighted red.

Plus, show the SnackBar with an error message. Once a user starts typing, the red color disappears.

Now, take a look at AppBar. The AppBar below is a default one.

Except for the Back button and no shadow (you can turn off in MaterialApp theme settings).

appBarTheme: AppBarTheme(

elevation: 0,

textTheme: TextTheme(title: FCIStyle.TitleWhite100Left),

), Scaffold(

appBar: title != null

? AppBar(

actions: actions,

leading: ModalRoute.of(context)?.canPop ?? false

? backButton(context, back: back)

: null,

title: Text(

title,

),

)

: null,

body: child,

)

Add the ModalRoute.of(context)?.canPop line to show the Back button only when the user has an option to return to the previous screen.

The button itself looks like this:

Widget backButton(context,

{String arrow = FCIAssets.arrowBackWhite, Widget back}) =>

IconButton(

icon: back ?? SvgPicture.asset(arrow),

tooltip: MaterialLocalizations.of(context).backButtonTooltip,

onPressed: () => Navigator.maybePop(context),

);

Next, let’s add a container with rounded edges that looks like the Toolbar extension. To do this, create a separate widget and reuse it on other screens.

class BasePageContainer extends StatelessWidget {

final Widget child; const BasePageContainer({Key key, this.child}) : super(key: key); @override

Widget build(BuildContext context) {

return LayoutBuilder(

builder: (BuildContext context, BoxConstraints viewportConstraints) {

return Container(

color: FCIColors.primaryBg,

child: Container(

child: SingleChildScrollView(

child: ConstrainedBox(

constraints: BoxConstraints(

minHeight: viewportConstraints.maxHeight,

),

child: IntrinsicHeight(child: child),

),

),

decoration: BoxDecoration(

color: FCIColors.whiteBg,

borderRadius: BorderRadius.only(

topLeft: Radius.circular(20.0),

topRight: Radius.circular(20.0),

),

),

),

);

},

);

}

} Widget build(BuildContext context) {return LayoutBuilder(builder: (BuildContext context, BoxConstraints viewportConstraints) {return Container(color: FCIColors.primaryBg,child: Container(child: SingleChildScrollView(child: ConstrainedBox(constraints: BoxConstraints(minHeight: viewportConstraints.maxHeight,),child: IntrinsicHeight(child: child),),),decoration: BoxDecoration(color: FCIColors.whiteBg,borderRadius: BorderRadius.only(topLeft: Radius.circular(20.0),topRight: Radius.circular(20.0),),),),);},);

We’ve created a full-height container with curved edges:

decoration: BoxDecoration(

color: FCIColors.whiteBg,

borderRadius: BorderRadius.only(

topLeft: Radius.circular(20.0),

topRight: Radius.circular(20.0),

),

),

And made a scroll for elements inside the container with SingleChildScrollView.

When a user clicks on the SignIn button, you send SignInEvent and handle it.

class SignInBloc extends BaseBloc {

var userRepository = FirebaseProvider.userRepository; @override

Stream<BaseState> mapEventToState(BaseEvent event) async* {

switch (event.runtimeType) {

case SignInEvent:

var result = _signInValidator(event);

yield result;

if (result is ProgressState) {

yield await _signInHandler(event);

}

break;

case GoToInitEvent:

yield GoToInitState();

break;

}

} Stream mapEventToState(BaseEvent event) async* {switch (event.runtimeType) {case SignInEvent:var result = _signInValidator(event);yield result;if (result is ProgressState) {yield await _signInHandler(event);break;case GoToInitEvent:yield GoToInitState();break; Future<BaseState> _signInHandler(SignInEvent event) => userRepository

.singIn(event.email, event.password)

.then((user) => SignInSuccessfulState(user.isCompleteProfile()))

.catchError((e) => handleError(e)); BaseState _signInValidator(SignInEvent event) {

if (!emailRegex.hasMatch(event.email)) {

return ErrorState(msg: "Wrong email", data: TextFieldType.email);

}

if (event.password.isEmpty) {

return ErrorState(msg: "Password is empty", data: TextFieldType.password);

}

if (event.password.length < 6) {

return ErrorState(

msg: "Password must be longer than 6 characters",

data: TextFieldType.password);

}

return ProgressState();

}

}

_signInValidator method checks the validity of input fields. When there’s a problem, it displays the error on SnackBar and highlights the invalid field.

enum TextFieldType { email, password, confirmPassword }

Now, the SignIn method:

Future<User> singIn(String email, String password) async => _firebaseAuth

.signInWithEmailAndPassword(email: email, password: password)

.then((result) => getCurrentUser()); Future<User> getCurrentUser() =>

_firebaseAuth.currentUser().then((FirebaseUser user) => _firestore

.document("${userCollection}/${user.uid}")

.get()

.then((document) => User.fromSnapshot(document)));

If an error shows up during the login, it’ll be handled with BaseState handleError(e) form BaseBloc. If everything’s good, it checks whether the profile is filled and opens the main screen. Or sends the user to the Complete Profile screen.

Users will have additional fields to fill like profile pic, birth data, first and last name, and phone number. That’s why you should store the model in Firebase Database where the document’s ID is authResult.user.uid.

Sign Up Screen

Sign up works similar to sign in. But in our case, you need to check if the user enters a new (not yet registered in the app) email address. If no user with such an email exists, we proceed with creating a new user and sending them to the Complete Profile screen.

Complete Profile Screen

We’re finally here. We used a simple Complete Profile screen with basic functionality:

Complete profile feature

There are three input fields on this screen. One is going to call the Date Picker like iOS. See a similar picker in Flutter gallery on GitHub.

One last thing is the profile picture. First, we need a File Image. The easiest way to get it is using the Image Picker plugin.

Image Picker lets you choose how to get the pic by using the device’s camera or gallery. Respectively, it returns null or file image.

The next step is cropping the pic. We’re going to use the Image Cropping plugin for that.

Cropping images

Image upload:

Future<String> uploadFile(String fileName, File file) => _firebaseStorage

.ref()

.child(fileName)

.putFile(file)

.onComplete

.then((data) => data.ref.getDownloadURL().then((url) => url));

To update the fields, use the code below. The reference is DocumentReference of the user:

Future<void> updateUser(CompleteProfileEvent event, String path) =>

reference.updateData({

'name': event.user.name,

'phone': event.user.phone,

'dob': event.user.dob,

'imageUrl': path ?? event.user.imageUrl

});

Finally, the user gets redirected to the main screen.

Profile Screen

Complete Screen and Profile Screen are much the same. There’s a difference though: instead of filling these fields in, you just show them. That’s the reason we’re going to describe the logout only here.

Profile screen

As said, there are email and Facebook login, so you should set up the logout this way:

Future<void> logOut() async {

await _firebaseAuth.signOut();

return facebookLogin.logOut();

}

List of Countries

This screen comes with a search-like button that redirects users to the search screen (we’ll talk about it later on). Next to it there’s a profile picture that redirects users to their profiles.

List of countries screen

But first, the search bar.

Widget _searchBar() {

return GestureDetector(

child: Padding(

padding: const EdgeInsets.only(

bottom: 16,

top: 16,

left: 12,

right: 12,

),

child: Material(

elevation: 10.0,

borderRadius: BorderRadius.circular(25.0),

child: Row(

children: <Widget>[

SizedBox(

width: 16,

),

SvgPicture.asset(FCIAssets.search),

Expanded(

child: Padding(

padding: const EdgeInsets.all(14.0),

child: Text(

FCIStrings.of(context).searchCountry,

style: FCIStyle.Body2Black38Left,

),

),

),

InkWell(

customBorder: CircleBorder(),

child: Hero(

tag: avatarTag,

child: Padding(

padding: const EdgeInsets.all(9),

child: Container(

height: 30,

width: 30,

child: CachedNetworkImage(

imageUrl: user?.imageUrl ?? "",

imageBuilder: (context, imageProvider) => Container(

decoration: BoxDecoration(

borderRadius: BorderRadius.circular(45),

image: DecorationImage(

image: imageProvider,

fit: BoxFit.cover,

),

),

),

placeholder: (context, url) =>

CircularProgressIndicator(),

errorWidget: (context, url, error) => Container(

child: Icon(

Icons.person,

color: FCIColors.white,

),

decoration: BoxDecoration(

color: FCIColors.accent,

borderRadius: BorderRadius.circular(45),

),

),

),

),

),

),

onTap: () {

Navigator.pushNamed(context, ProfilePage.routeName);

},

),

],

),

),

),

onTap: () {

Navigator.pushNamed(context, SearchPage.routeName);

},

);

}

Moving on to the list of countries, use the following query:

Future<List<Country>> getCountries({orderBy = orderByCountryDefault}) =>

_firestore

.collection(countryCollection)

.orderBy(orderBy)

.limit(paginationLimit)

.getDocuments()

.then((snapshot) => snapshot.documents

.map((country) => Country.fromJson(

country.data,

doc: country,

))

.toList());

There can be a ton of data, so you may want it to load in parts. Plus, set a limit on their account. We used final paginationLimit = 25.

When you get the list of documents, parse them into the models:

class Country {

String name;

String capital;

String imagePreview;

String bigImagePreview;

List<String> images; DocumentSnapshot doc; Country({

this.name,

this.capital,

this.imagePreview,

this.bigImagePreview,

this.images,

}); Country.fromJson(Map<String, dynamic> json, {DocumentSnapshot doc}) {

name = json['name'];

capital = json['capital'];

imagePreview = json['imagePreview'];

bigImagePreview = json['bigImagePreview'];

images = json['images'].cast<String>();

this.doc = doc;

} Map<String, dynamic> toJson() {

final Map<String, dynamic> data = Map<String, dynamic>();

data['name'] = this.name;

data['capital'] = this.capital;

data['imagePreview'] = this.imagePreview;

data['bigImagePreview'] = this.bigImagePreview;

data['images'] = this.images;

return data;

}

}

Then send the state to the BlocBuilder and show the list.

Here’s a question: how to load more data when users scroll already loaded parts?

Use final _scrollController = ScrollController() for that.

ListView.builder(

controller: _scrollController,

itemCount: state.isNoMore ? itemCount : itemCount + 1,

itemBuilder: (BuildContext context, int index) {

return index >= state.counties.length

? _bottomLoader()

: Padding(

padding: EdgeInsets.only(

top: index == 0 ? 42 : 0,

bottom: 12,

left: 12,

right: 12,

),

child: _countryWidget(state.counties[index], index),

);

}),

Pass it to the ListView and set field itemCount state.isNoMore ? itemCount : itemCount + 1.

isNoMore shows if there is more data on a server available for loading. If not, add another element to the list showing the loading progress.

And you should override scroll changes. Use initState method for that.

@override

void initState() {

_bloc.dispatch(GetListOfCountiesEvent());

_scrollController.addListener(_onScroll);

super.initState();

} void initState() {_bloc.dispatch(GetListOfCountiesEvent());_scrollController.addListener(_onScroll);super.initState(); void _onScroll() {

final maxScroll = _scrollController.position.maxScrollExtent;

final currentScroll = _scrollController.position.pixels;

if (maxScroll - currentScroll <= _scrollThreshold &&

_bloc.currentState is! ProgressLoadMoreState &&

!_isNoMore) {

_bloc.dispatch(LoadMoreCountriesEvent());

}

}

Send an even to load data to Block and handle it when users go to the end of the list.

Here’s a loading method for Firebase:

Future<List<Country>> loadMoreCountries(DocumentSnapshot document,

{orderBy = orderByCountryDefault}) =>

_firestore

.collection(countryCollection)

.orderBy(orderBy)

.startAfterDocument(document)

.limit(paginationLimit)

.getDocuments()

.then((snapshot) => snapshot.documents

.map((country) => Country.fromJson(country.data, doc: country))

.toList());

See the document parameter? It’s the last loaded element needed to continue data loading from the point the user stopped at.

Update and show the list to users after receiving the data.

Search By Countries

For user convenience, add search for countries by names.

Search by countries

AppBar with search:

AppBar(

elevation: 6,

backgroundColor: FCIColors.white,

leading:

BaseScaffold.backButton(context, arrow: FCIAssets.arrowBackBlack),

title: _buildSearchField(),

actions: _buildActions(),

), List<Widget> _buildActions() => <Widget>[

IconButton(

icon: const Icon(

Icons.clear,

color: Color(0x60000000),

),

onPressed: () {

if (_searchQuery == null || _searchQuery.text.isEmpty) {

Navigator.pop(context);

return;

}

_clearSearchQuery();

},

),

]; Widget _buildSearchField() => TextField(

controller: _searchQuery,

autofocus: true,

decoration: InputDecoration(

hintText: FCIStrings.of(context).searchCountry,

border: InputBorder.none,

hintStyle: FCIStyle.Body2Black38Left,

),

style: FCIStyle.Body1Black100Left,

onChanged: _updateSearchQuery,

);

As soon as users finish entering the text, make a request for getting data:

Future<List<Country>> searchCountry(String query,

{orderBy = orderByCountryDefault}) =>

_firestore

.collection(countryCollection)

.orderBy(orderBy)

.startAt([

query.substring(0, 1).toUpperCase() +

query.substring(1).toLowerCase()

])

.endAt([

query.substring(0, 1).toUpperCase() +

query.substring(1).toLowerCase() +

"\uf8ff"

])

.getDocuments()

.then((docs) => docs.documents

.map((item) => Country.fromJson(item.data, doc: item))

.toList());

Mind this: Firebase has restrictions on text search. Still, the option above will work as an example.

Show the list once you get it. To create a separator, use ListView.separated(). It’s similar to ListView.builder but with an additional parameter: separatorBuilder: (BuildContext context, int index) => YourSeparatedWidget. There you set the separator on the widget.

Detail Page Screen

The detail page shows travelers’ reviews on the countries they’ve visited.

Review screen

Use CustomScrollView with SliverPersistentHeader and SliverList to build the appBar.

CustomScrollView(

controller: _scrollController,

slivers: [

SliverPersistentHeader(

delegate: SliverAppBarDelegate(330, arg.country.name,

arg.country.capital, arg.country.imagePreview),

pinned: true,

),

SliverList(

delegate: SliverChildBuilderDelegate(

(BuildContext context, index) => ListView(...),

childCount: 1,

),

)

],

)

Start with SliverAppBarDelegate which is your AppBar. Then set the height and transmit the parameters.

final double expandedHeight;

final String name;

final String capital;

final String imagePreview; SliverAppBarDelegate(

this.expandedHeight, this.name, this.capital, this.imagePreview); double scrollAnimationValue(double shrinkOffset) {

double maxScrollAllowed = maxExtent - minExtent;

return ((maxScrollAllowed - shrinkOffset) / maxScrollAllowed)

.clamp(0, 1)

.toDouble();

} @override

Widget build(

BuildContext context,

double shrinkOffset,

bool overlapsContent,

) {

final double visibleMainHeight =

math.max(maxExtent - shrinkOffset, minExtent);

final double animationVal = scrollAnimationValue(shrinkOffset); Widget build(BuildContext context,double shrinkOffset,bool overlapsContent,) {final double visibleMainHeight =math.max(maxExtent - shrinkOffset, minExtent);final double animationVal = scrollAnimationValue(shrinkOffset); return Container(

height: visibleMainHeight,

width: MediaQuery.of(context).size.width,

child: Stack(

children: <Widget>[

Container(

margin: EdgeInsets.only(bottom: 2), color: FCIColors.primary),

Opacity(

opacity: animationVal < 0.4 ? animationVal / 0.4 : 1,

child: Hero(

tag: imagePreviewTag,

child: Container(

height: expandedHeight,

width: double.infinity,

margin: EdgeInsets.only(bottom: 2),

child: CachedNetworkImage(

imageUrl: imagePreview,

fit: BoxFit.cover,

placeholder: (ctx, url) => SvgPicture.asset(

FCIAssets.countryPlaceholder,

color: FCIColors.white,

),

),

),

),

),

Opacity(

opacity: animationVal < 0.4 ? animationVal / 0.4 : 1,

child: Container(

margin: EdgeInsets.only(bottom: 2),

decoration: BoxDecoration(

gradient: LinearGradient(

begin: Alignment.topCenter,

end: Alignment.bottomCenter,

colors: [

Color(0x00000000),

Color(0x77000000),

],

),

),

),

),

Positioned(

bottom: 0.0,

child: SizedBox(

width: MediaQuery.of(context).size.width,

child: Padding(

padding: EdgeInsets.only(

left: math.min(115 - animationVal * 100, 72), bottom: 16),

child: Padding(

padding: EdgeInsets.only(bottom: 17),

child: Column(

crossAxisAlignment: CrossAxisAlignment.start,

mainAxisSize: MainAxisSize.min,

children: <Widget>[

Text(

name,

style: TextStyle(

fontFamily: 'Metropolis',

color: Color(0xffffffff),

fontSize: 20 + (animationVal * 8),

fontWeight: FontWeight.w600,

fontStyle: FontStyle.normal,

letterSpacing: 0.24,

),

),

animationVal * 100 < 5

? SizedBox()

: AutoSizeText(

capital,

textAlign: TextAlign.start,

style: TextStyle(

fontFamily: 'Metropolis',

color: Color(0xffffffff),

fontSize: 16,

fontWeight: FontWeight.w400,

fontStyle: FontStyle.normal,

),

),

],

),

),

),

),

),

Positioned(top: 5, left: 5, child: BaseScaffold.backButton(context)),

Positioned(

bottom: 0,

child: Container(

height: 16,

width: MediaQuery.of(context).size.width,

decoration: BoxDecoration(

color: FCIColors.white,

borderRadius: BorderRadius.only(

topLeft: Radius.circular(16),

topRight: Radius.circular(16))),

),

)

],

),

);

} @override

double get maxExtent => expandedHeight; double get maxExtent => expandedHeight; @override

double get minExtent => kToolbarHeight + 16; double get minExtent => kToolbarHeight + 16; @override

bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => true;

} bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => true;

That’s how you shift text. The app will show a country’s name or the country plus its capital, depending on AppBar’s size.

Then the list of photos:

ListView.builder(

scrollDirection: Axis.horizontal,

itemCount: images.length,

itemBuilder: (ctx, index) =>

_imageWithDecorator(images, index),

),

And the photo widget:

Widget _imageWithDecorator(List<String> urls, int index) => InkWell(

child: Hero(

tag: "$fullImageTag$index",

child: CachedNetworkImage(

imageUrl: urls[index],

imageBuilder: (ctx, img) => Container(

height: 120,

width: 120,

margin: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: 8),

decoration: BoxDecoration(

image: DecorationImage(image: img, fit: BoxFit.cover),

borderRadius: BorderRadius.circular(16),

),

),

placeholder: (ctx, url) => Container(

height: 120,

width: 120,

margin: EdgeInsets.only(left: 8, right: 8, top: 8, bottom: 8),

child: Padding(

padding: const EdgeInsets.all(8.0),

child: SvgPicture.asset(FCIAssets.countrySmallPlaceholder),

),

decoration: BoxDecoration(

color: Color(0xFFf5f5f5),

borderRadius: BorderRadius.circular(16)),

),

),

),

onTap: () {

Navigator.pushNamed(context, FullPhotoPage.routeName,

arguments: FullPhotoArgument(urls, index));

},

);

To handle clicks, use InkWell and Hero to transition animation for the pics. When the user clicks on a photo, it’ll transition smoothly between screens.

Next comes the list of comments. Actually, there may be quite a lot of them, so we suggest loading them in parts as the user scrolls the page. (yeah, we’ve considered data loading with page numbering.)

ListView.builder(

itemCount:

_isNoMore ? listData.length : listData.length + 1,

shrinkWrap: true,

physics: ClampingScrollPhysics(),

itemBuilder: (ctx, index) => index >= listData.length

? _progres()

: _message(

listData[index]),

),

Leave a Comment Screen

Everything’s pretty standard here.

Add comment

The screen comes with a text field and a button to add images. If users skip adding text, they won’t be able to click the Send comment button.

Add a variable keeping the last modified text to call the setState function less frequently. The button gets active/inactive when calling the setState.

@override

void initState() {

_messageController.addListener(() {

var text = _messageController.text.trim();

if (_lastText.isEmpty && text.isNotEmpty) {

setState(() {

_lastText = text;

});

} else if (_lastText.isNotEmpty && text.isEmpty) {

setState(() {

_lastText = text;

});

}

});

super.initState();

} void initState() {_messageController.addListener(() {var text = _messageController.text.trim();if (_lastText.isEmpty && text.isNotEmpty) {setState(() {_lastText = text;});} else if (_lastText.isNotEmpty && text.isEmpty) {setState(() {_lastText = text;});});super.initState();

We already know how to get images from the gallery or devices’ cameras. Let’s move on to the comments.

When a user clicks the button the event is sent to bloc:

_onSend() {

_bloc.dispatch(CreateCommentEvent(_messageController.text.trim(), image,

currentCountry.doc.reference.path));

}

The function of creating a message in bloc:

Future<BaseState> _createComment(CreateCommentEvent event) => userRepository

.getCurrentUser()

.then((user) => _uploadImageIfExist(event.image)

.then((imageUrl) => Comment(

message: event.message,

image: imageUrl,

userRef: user.reference,

createdAt: DateTime.now().millisecondsSinceEpoch,

))

.then((comment) => countryRepository

.createComment(event.path + commentsPath, comment)

.then((_) => CreatedCommentState())))

.catchError((e) => handleError(e)); Future<String> _uploadImageIfExist(File file) {

if (file != null) {

return userRepository.uploadFile(Uuid().v4(), file);

} else {

return Future.value("");

}

}

And of sending a comment to Firestore:

Future<void> createComment(String path, Comment comment) => _firestore

.collection(path)

.document(Uuid().v4())

.setData(comment.toJson());

How is the data being stored on Firebase?

In the countries document, you have a collection of comments with the link to image and message, the creation date and link to the user who left it.

You may want to specify the createAt field as timestamp but a number will do for this example.

Wrapping Up

That’s basically all you need to know to build a simple travel app with Flutter. I’ve tried to include the most commonly used travel app features to show you how to build them using Flutter framework.

Go ahead and try it out.