Are you looking to create a job portal app? Look no further in this tutorial series we will build from scratch a fully functional job portal in flutter.

In part one of this tutorial you will start by building the two main screens of the app, then in the next you will learn how to add animation and lastly integrate an API and make the app fully functional. Exciting isn’t it?

To get started download the source code from my Github repo: https://github.com/cybdom/flutter-job-portal

I suggest following me on Twitter in order to be notified of the upcoming episodes of this exciting journey at https://twitter.com/cybdom

On last thing, coffee is a source of motivation for me. Please buy me one at https://www.buymeacoffee.com/bi3cp0Zk5

Home Screen:





The idea here is to use a Stack widget that will allow you to have the custom bottom sheet appear when the user clicks on the button, as well as to put the custom bottom navbar at the bottom of the screen.

Header:

Instead of a traditional AppBar widget that could honestly perfectly do the job, we instead use a Row. It’s first child is a DropDownButton, then add a Spacer to push the search IconButton and the CircularAvatar to the far right.

Row( children: [ MyDropDownButton(), Spacer(), IconButton( icon: Icon( Icons.search, color: Colors.black87, ), onPressed: () {}, ), CircleAvatar( backgroundImage: NetworkImage("https://cdn.pixabay.com/photo/2017/06/09/07/37/notebook-2386034_960_720.jpg"), ) ], ),

Now for the search TextField and the filter button, put the two inside a Container and set its height to something like 51 in order for the two to have the same height and not look odd.

Container( height: 51, child: Row( children: [ Expanded( child: TextField( decoration: InputDecoration( prefixIcon: Icon(Icons.search), hintText: "Search", border: OutlineInputBorder( borderRadius: BorderRadius.circular(15.0), borderSide: BorderSide.none), fillColor: Color(0xffe6e6ec), filled: true, ), ), ), SizedBox(width: 15), Container( decoration: BoxDecoration( color: Color(0xffe6e6ec), borderRadius: BorderRadius.circular(9.0), ), child: IconButton( icon: Icon(Icons.tune), onPressed: () { Provider.of (context) .changeState(); }, ), ), ], ), ),

Don’t ask me about the Provider we will talk about it later.

Job Posting Container:

A ListViewBuilder has to return a Widget and in this case it will be the job offer container:

Expanded( child: ListView.builder( itemCount: jobList.length, itemBuilder: (ctx, i) { return JobContainer( description: jobList[i].description, iconUrl: jobList[i].iconUrl, location: jobList[i].location, salary: jobList[i].salary, title: jobList[i].title, onTap: () => Navigator.push( context, MaterialPageRoute( builder: (ctx) => DetailsScreen(id: i), ), ), ); }, ), )

The Job Offer Container here is pretty simple. It just has margin, padding, a white background, and a box shadow.

Inside a column are all the text widgets and the company logo inside a ClipRRect with a circular border radius.

here is the full code:

import 'package:flutter/material.dart'; class JobContainer extends StatelessWidget { const JobContainer({ Key key, @required this.iconUrl, @required this.title, @required this.location, @required this.description, @required this.salary, @required this.onTap, }) : super(key: key); final String iconUrl, title, location, description, salary; final Function onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( margin: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 5.0), padding: const EdgeInsets.all(15.0), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(9.0), boxShadow: [ BoxShadow( color: Colors.grey[300], blurRadius: 5.0, offset: Offset(0, 3)) ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(15.0), child: Image.network( "$iconUrl", height: 71, width: 71, ), ), SizedBox(width: 15), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "$title", style: Theme.of(context).textTheme.title, ), Text( "$location", style: Theme.of(context).textTheme.subtitle.apply( color: Colors.grey, ), ) ], ), ) ], ), SizedBox(height: 5), Text( "$description", style: Theme.of(context).textTheme.body1.apply(color: Colors.grey), maxLines: 3, overflow: TextOverflow.ellipsis, ), SizedBox(height: 9), Text( "$salary", style: Theme.of(context).textTheme.subhead.apply(fontWeightDelta: 2), ) ], ), ), ); } }

Bottom Nav:

Back to the main widget of the main screen, the Stack.

Positioned( bottom: 0, left: 0, right: 0, height: 60, child: MyBottomNavBar(), ),

It doesn’t seem like it but this is one of the largest widgets in terms of code size, may be did I do something wrong or inefficiently? Let me know in the comments.

In a StateFulWidget called MyBottomNavBar, create a variable of type int called active. This will allow you to track the current active item.

The widget itself returns a Container with these parametres:

padding: EdgeInsets.all(5.0), height: double.infinity, width: double.infinity, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(25), topRight: Radius.circular(25), ), ),

And it has a Row of MyBottomNavItem as a child.

import 'package:flutter/material.dart'; class MyBottomNavItem extends StatelessWidget { const MyBottomNavItem({ Key key, @required int active, @required this.onPressed, @required this.id, @required this.icon, @required this.text, }) : _active = active, super(key: key); final GestureTapCallback onPressed; final int _active; final int id; final IconData icon; final String text; @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ _active == id ? Text( text, style: Theme.of(context) .textTheme .overline .apply(color: Colors.red), ) : Container(), _active == id ? Container() : Flexible( child: IconButton( icon: Icon( icon, color: Colors.grey, ), onPressed: onPressed, ), ), Flexible( child: _active == id ? Container( margin: EdgeInsets.only(top: 9.0), height: 5, width: 5, decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.red), ) : Container( height: 5, width: 5, ), ), ], ); } }

Bottom Sheet:

Now the bottom sheet, hold tight!

import 'package:flutter/material.dart'; import 'package:flutter_job_portal/models/bottomsheet.dart'; import 'package:flutter_job_portal/ui/widgets/experiencelevelwidget.dart'; import 'package:provider/provider.dart'; class MyBottomSheet extends StatefulWidget { @override _MyBottomSheetState createState() => _MyBottomSheetState(); } class JobTypes { final String title; bool checked; final int count; JobTypes({this.title, this.checked, this.count}); } class _MyBottomSheetState extends State { List jobTypes = [ JobTypes(title: "Full-Time", checked: false, count: 135), JobTypes(title: "Part-Time", checked: false, count: 235), JobTypes(title: "Contract", checked: false, count: 39), JobTypes(title: "Internship", checked: false, count: 59), JobTypes(title: "Temporary", checked: false, count: 21), JobTypes(title: "Commission", checked: false, count: 3), ]; RangeValues _rangeValues = RangeValues(0, 300000); @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.all(15.0), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(25.0), topRight: Radius.circular(25.0), ), ), child: Column( children: [ Text( "Salary Estimate", style: Theme.of(context).textTheme.title, ), RangeSlider( min: 0, max: 300000, values: _rangeValues, onChanged: (rangeValue) { setState(() { _rangeValues = rangeValue; }); }, labels: RangeLabels( _rangeValues.start.toString(), _rangeValues.end.toString()), ), Text( "Job Type", style: Theme.of(context).textTheme.title, ), GridView.count( shrinkWrap: true, childAspectRatio: 3, crossAxisCount: 2, children: List.generate( jobTypes.length, (i) { return Row( children: [ Checkbox( value: jobTypes[i].checked, onChanged: (value) { setState(() { jobTypes[i].checked = value; }); }, ), Text("${jobTypes[i].title} (${jobTypes[i].count})"), ], ); }, ), ), Text( "Experience Level", style: Theme.of(context).textTheme.title, ), ExperienceLevelWidget(), Container( height: 40, width: double.infinity, margin: EdgeInsets.symmetric(horizontal: 25.0), child: RaisedButton( color: Colors.blue, child: Text( "Submit", style: Theme.of(context) .textTheme .button .apply(color: Colors.white), ), onPressed: () => Provider.of (context) .changeState(), ), ) ], ), ); } }

First you can see the JobTypes class which actually should be in its own file in the models folder, the class takes three variables a title of type String, a count of type int, and a checked of type boolean.

Then for the demonstration purpose of this first tutorial there is a static list of job types that we can select from.

The container design here is just the same as the bottom navbar. (While writting this I noticed that I could’ve replaced the content of the bottom nav bar with the bottom sheet content).

Clicking on the submit button changes the state of the bottom sheet and makes it disappear using the Provider Package.

Back to the home page

Provider.of (context).visible ? Positioned( bottom: 0, left: 0, right: 0, // height: MediaQuery.of(context).size.height / 1.3, child: MyBottomSheet(), ) : Container(),

While the Provider.of(context).visible value is true, show MyBottomSheet at the bottom of the screen. Let’s take a look at the MyBottomSheetModel:

import 'package:flutter/material.dart'; class MyBottomSheetModel extends ChangeNotifier{ bool _visible = false; get visible => _visible; void changeState(){ _visible = !_visible; print(_visible); notifyListeners(); } }

First a _visible variable is set to false by default. Use a getter to get its value. Then when the changeState function is called it sets the value of _visible to it’s opposite and notifies the listeners. Easy isn’t it?

Details Screen:

I am a real fan of the Stack widget and I think it shows. Here again it is the main widget.

Positioned( top: 0, left: 0, right: 0, height: MediaQuery.of(context).size.height / 2, child: Image.network( "https://cdn.pixabay.com/photo/2015/01/08/18/26/write-593333_960_720.jpg", fit: BoxFit.cover, color: Colors.black38, colorBlendMode: BlendMode.darken, ), ),

First element to position here is the job image. It could be a gallery depending on the job posting API that you have.

When putting text or icons on top of images it is usually a good idea to add a slight filter to the images to ensure a good enough contrast. Do that using the colorBlendMode in flutter.

Positioned( top: 0, left: 0, right: 0, child: Row( children: [ IconButton( icon: Icon( Icons.chevron_left, color: Colors.white, ), onPressed: () => Navigator.pop(context), ), Spacer(), IconButton( icon: Icon( Icons.favorite, color: Colors.white, ), onPressed: () {}, ), IconButton( icon: Icon( Icons.file_upload, color: Colors.white, ), onPressed: () {}, ), ], ), ),

Instead of using an appbar here again it’s a Row. Three IconButtons, separating the first of the later with a Spacer widget.

Positioned( left: 0, right: 0, bottom: 0, height: MediaQuery.of(context).size.height / 2, child: Container( padding: const EdgeInsets.all(15.0), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(25), topRight: Radius.circular(25), ), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "${jobList[id].title}", style: Theme.of(context).textTheme.headline, ), Text( "${jobList[id].location}", style: Theme.of(context) .textTheme .body2 .apply(color: Colors.grey), ), SizedBox( height: 15.0, ), Text( "Overview", style: Theme.of(context).textTheme.subhead, ), Text( "${jobList[id].description}", style: Theme.of(context) .textTheme .body2 .apply(color: Colors.grey), maxLines: 3, ), SizedBox( height: 15.0, ), Text( "Photos", style: Theme.of(context).textTheme.subhead, ), SizedBox(height: 5), Container( height: 80, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: jobList[id].photos.length, itemBuilder: (ctx, i) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 9.0), child: ClipRRect( borderRadius: BorderRadius.circular(15.0), child: Image.network("${jobList[id].photos[i]}"), ), ); }, ), ), SizedBox( height: 15.0, ), Container( width: MediaQuery.of(context).size.height * .7, height: 45, child: RaisedButton( child: Text( "Booking Inquiry", style: Theme.of(context) .textTheme .button .apply(color: Colors.white), ), color: Colors.blue, onPressed: () {}, ), ) ], ), ), ), )

To ensure there is enough space wrap the container content in a SingleChildScollView.

The widgets inside are the basic ones. Mostly text widgets showing the job info, SizedBoxes for margins, and a button to submit your inquiry.

What is coming next:

In the next tutorial we will try to replicate these animations using flutter. I think it will be a lot of fun so stay tuned!

Don’t forget to follow me on Twitter at https://twitter.com/cybdom.

Do you want me to publish the tutorial faster? Buy me some coffee at https://www.buymeacoffee.com/bi3cp0Zk5. More Coffee = More Motivation = Faster delivery!

See you next time!