For the Coding Roulette Session:
Let's show a schematic view of what we'll be tackling with the LandingPage widget below:
We'll build a simple landing page for our application. Initially, this app displays a splash screen, waits two seconds, then proceeds to show the landing page we'll create below. Afterwards, this landing page will navigate users to a list page.
Let's start!
Assuming you've already completed the previous labs, you should already have the project's main MaterialApp launch the SplashScreen page initially. For development purposes, let's point the MaterialApp's home property to the new page we'll create, which we'll call LandingPage. Go ahead and in the main method, inside the MaterialApp, replace the home property from SplashPage to LandingPage (or comment it out since we'll need it at the end) as follows:
Negative Don't worry about the red squiggly line under the LandingPage widget; we haven't created this class yet, that's why it's complaining. We'll create it in a minute.
Let's proceed now and create the corresponding class.
Let's create the class that represents this new widget. Anywhere in this file (most conveniently at the bottom), add a new class called LandingPage that extends StatelessWidget. As usual, override its build method, and return some placeholder content: a Scaffold widget and for its body property, a Text widget with the content "Landing Page":
Positive In a full-blown Flutter project, of course you'd create separate files for each of your widgets, which is the recommended approach. Since we're doing this in DartPad, we create all classes on a single file.
Running whatever we have in DartPad, then viewing the output on the Preview Page, you should get the following:
Click Next and let's move on!
We'll eliminate our placeholder Text widget from the Scaffold's body property and replace it by the root of the body structure, which will be a Stack widget. We'll use a Stack widget since we'll lay out the children widgets one of top of each other in a layered fashion, starting with the background image.
First replace the Text widget with a Stack widget with empty *children property (assign it an empty array):
As the first child of this Stack widget, and going by our design, we will have a background image as the bottommost element, then all the elements will go at the top.
Add a Container widget that will serve as the host of the image, which will be set as its background, by using the decoration property and set the BoxDecoration's image property to a DecorationImage, whic in turn we'll set a NetworkImage widget to the image's property of the DecorationImage widget. Set the DecorationImage's fit property to BoxFit.cover so it covers the whole Container area but without losing its aspect ratio:
Positive For the image, you can use the image in this link or from a similar royalty-free image site.
Running whatever we have accomplished so far in DartPad, you get the following in the Preview:
We want to add a simple Container widget with a faded color to give a color overlay effect. This is easily accomplished by adding another Container widget to the Stack (on top of the image) and just lowe its opacity.
Simple as adding a Container widget, and set its color property to the mainThemeColor and set a 70% opacity by using the withOpacity method of the Color object:
Check out how it came out by running it on DartPad now:
So far so good, so it's time to lay down the content pieces. On top of the background image and color overlay, we'll place a Column widget with a bunch of content. Add the Column widget and set both main axis and cross axis alignments to center and stretch respectively. Add an empty children property for now:
Since this is a Column widget, its items will be laid out vertically, from top to bottom.
Start by adding the first child to this Column: a Text widget; center aligned, and with a TextStyle with a white color, bold font, and font size 30px; add the content "Paradise" to it:
Let's keep adding items to this Column. We want to leave some breathing space between the items we'll be placing, so add a SizedBox with 60px height to denote some spacing here - add it right under the previous Text widget:
Moving right along, we'll add the app's designated icon (Icons.pool) with a color white with 80px in size; and while you're at it, add a SizedBox with 10px height for spacing purposes:
Let's add another Text widget for another label, with centered text, white color with a 50% opacity, and all the letters in uppercase. Since you're in that area, add another spacing SizedBox widget with 5px in height, as follows:
Let's add another Text widget, also with its text centered, white, 30px and bold, with the content "Find a Hotel". Add also another SizedBox with 20px height right underneath it:
If you run the app in DartPad at this moment, you should see the following on the Preview Pane:
Looking good! Now as the last item in this Column widget, and following our design, we'll add the search bar. But instead of building it inside of the Column using the core widgets available, we'll actually encapsulated into its own custom widget, which we'll call LandingSearchBar.
We'll work on this custom widget on the next step. Click Next to proceed.
The schematic figure below shows how you'd build a custom search bar layout (just the layout, no editable text field) and encapsulate its structure within a custom widget.
Let's pick it up where we left off on the previous step.
As the last item of the Column widget, add a placeholder widget called LandingSearchBar. We will create it in a minute:
Let's create now the corresponding class for this landing search bar widget. Create a class called LandingSearchBar that extends StatelessWidget as customary for custom widgets that won't hold state within themselves. As usual, return a dummy Container widget as the return statement from the overridden *build method:
Now let's proceed to further define the structure of this widget.
Add left and right margin, 30px to this Container:
Add padding of 5px (top, bottom and right), and 20px (left):
For the white background on the main Container and the rounded corners, use the decoration property and set it as a BoxDecoration; set its color to white, and borderRadius of 50:
Now let's focus on the contents of this Container.
As the child of this Container, add a Row widget - this is what will hold the placeholder text (a Text widget) and a circular search button (a styled Container), using a spaceBetween strategy laying down its children on the main axis; set the Row's children property to an empty array for now:
First child of this row will be the Search Hotel Text widget, so add the Text widget, content should say "Search Hotel", with a grey color:
Let's take a pause to run what we have on DartPad, and confirm that you can see the following:
We'll create a custom round tappable button with an icon that our users will be able to tap, which will perform a navigation to a list page once we're done.
Let's build it!
Inside the Row widget above, right next to the Text widget we created, add a Container widget, dimensions 30px by 30px, and use the same decoration property to apply both the background color (mainThemeColor) of the Container and border radius (BorderRadius.circular(25)); this applies the roundness to the Container making it look like a circle.
Add an Icon widget as the child of this styled Container; add the icon Icons.search, with a white color and 15px in size:
In order to allow your widget elements to capture user gestures and events (i.e. become tappable and trigger events), you can wrap them inside a handy widget called GestureDetector. Since we want to make our rounded Container with the search icon tappable, wrap it inside a GestureDetector widget by setting this Container as the child of the GestureDetector widget; hook up its onTap event to an empty callback for now, as follows:
Now it's time to implement the onTap event: we'll perform a simple navigation to the next page in turn (ListPage) upon tapping on our custom circular search button.
Inside the onTap event, use the Navigator.context provision to access the navigator context provided by Flutter, and push a MaterialPageRoute, which wraps the page that we want to navigate to - the ListPage widget, as follows:
Now, if you take this for a spin, you should be able to tap on the circular search button inside the LandingSearchBar widget, which in turn will navigate you to the ListPage we created on lab #2. The screenshot below is what you should be seeing after completing the above steps:
In order to stay true to the design, we will have to add a simple AppBar and Drawer widget to the LandingPage's Scaffold widget - two of the essential widgets that get added to a Scaffold to consider a Material-based UI design complete.
Let's proceed.
Usually the AppBar gets assigned to the Scaffold widget by setting its appBar property to an AppBar widget. An AppBar widget - just like the name implies - is the application's topmost bar, where we can place a title, navigation icons, menu icons, etc.
In our case, we want the AppBar to look on top of the background image; for this effect to occur, we must add it to the existing Stack widget, right under the Column widget.
Let's proceed by adding the AppBar to the Stack and setting the following properties to it:
Your code should look as follows:
Taking it for a spin on DartPad by running it, it should look like this:
Notice how the AppBar shows transparent, but shows the back chevron icon to go back to the splash screen. In order to make it show the hamburger menu to expand the drawer menu widget, you must add the Drawer widget.
Let's do that now.
Inside of the Scaffold, let's populate now the drawer property by assigning to it a Drawer widget. The Drawer widget is a panel that slides out horizontally from the edge of a Scaffold widget, used for menus, navigation links, etc.
Your code should look as follows:
Now try expanding the drawer by tapping on the hamburger menu. It looks a little plain now.
Let's add some content to this widget.
Let's add some content to the Drawer widget and customize it a little bit. A Drawer is pretty much like any other single-child widget, in which you can add anything inside it and it will render as you please.
First, let's start by adding a Container widget as the child of the Drawer widget, and set its padding to 20px all around using EdgeInsets.all(20). By default, its background color is white so let's leave it as is. Align its contents to the bottom left corner by setting its alignment property to Alignment.bottomLeft:
Now, add an Icon widget as the child of the newly added Container widget, with our specific icon (Icons.pool), color of mainThemeColor and size 80. Your code should look like this:
And with that, you should be all set with customizing the Drawer widget. You can feel free to add further elements and links to your own Drawer widgets, since it provides a lot of flexibility for customization. Check it out after running it on DartPad:
Sweet! let's wrap things up on the next step. Click Next to continue.
Let's set up the app's final workflow which is:
In order to accomplish this, let's start by reverting the change we made in the main method, inside the MaterialApp's home property - set this property back to be the SplashPage instead of the LandingPage:
In the SplashPage, inside the Future.delayed() method, instead of navigating to the ListPage after the 2 seconds have ellapsed, change it to point to the LandingPage widget:
And with that, we wrap up the codelab series for this workshop, where we accomplished the following:
In case you fell behind on this codelab, below is the whole code for this codelab in a way you can copy / paste directly into DartPad:
import 'package:flutter/material.dart';
const Color mainThemeColor = Color(0xFF272D8D);
final List<Attraction> attractionsList = [
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/275162028.jpg?k=38b638c8ec9ec86624f9a598482e95fa634d49aa3f99da1838cf5adde1a14521&o=&hp=1',
name: 'Grand Bavaro Princess',
desc: 'All-Inclusive Resort',
location: 'Punta Cana, DR',
rating: 3,
price: 80.0),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/232161008.jpg?k=27808fe44ab95f6468e5433639bf117032c8271cebf5988bdcaa0a202b9a6d79&o=&hp=1',
name: 'Hyatt Ziva Cap Cana',
desc: 'All-Inclusive Resort',
price: 90.0,
rating: 4,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/256931299.jpg?k=57b5fb9732cd89f308def5386e221c46e52f48579345325714a310addf819274&o=&hp=1',
name: 'Impressive Punta Cana',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 5,
location: 'Punta Cana, DR'),
Attraction(
imgPath:
'https://cf.bstatic.com/xdata/images/hotel/max1024x768/283750757.jpg?k=4f3437bf1e1b077463c9900e4dd015633db1d96da38f034f4b70a4ba3ef76d82&o=&hp=1',
name: 'Villas Mar Azul Dreams',
desc: 'All-Inclusive Resort',
price: 100.0,
rating: 4,
location: 'Tallaboa, PR'),
];
final List<BottomBarItem> barItemsList = [
BottomBarItem(label: 'Home', isSelected: true, icon: Icons.home),
BottomBarItem(label: 'Account', isSelected: false, icon: Icons.person),
BottomBarItem(label: 'Bookings', isSelected: false, icon: Icons.pending_actions),
BottomBarItem(label: 'Payments', isSelected: false, icon: Icons.payments),
BottomBarItem(label: 'More', isSelected: false, icon: Icons.more_horiz),
];
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner: false,
home: SplashPage()
)
);
}
class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
Future.delayed(const Duration(seconds: 2), () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => LandingPage())
);
});
return Stack(
children: [
Container(
color: mainThemeColor
),
Align(
alignment: Alignment.center,
child: Icon(
Icons.pool,
color: Colors.white,
size: 80
)
),
Align(
alignment: Alignment.bottomCenter,
child: LinearProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white.withOpacity(0.4)
)
)
)
],
);
}
}
class LandingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
drawer: Drawer(
child: Container(
padding: EdgeInsets.all(20),
alignment: Alignment.bottomLeft,
child: Icon(Icons.pool, color: mainThemeColor, size: 80)
)
),
body: Stack(
children: [
Container(
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage('https://images.pexels.com/photos/261394/pexels-photo-261394.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940'),
fit: BoxFit.cover
)
)
),
Container(color: mainThemeColor.withOpacity(0.7)),
AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
iconTheme: IconThemeData(color: Colors.white),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Paradise',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 30
)
),
SizedBox(height: 60),
Icon(Icons.pool, color: Colors.white, size: 80),
SizedBox(height: 10),
Text('Choose location to'.toUpperCase(),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white.withOpacity(0.5))
),
SizedBox(height: 5),
Text('Find a Hotel', textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.bold
)
),
SizedBox(height: 20),
LandingSearchBar()
]
),
],
)
);
}
}
class LandingSearchBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(left: 30, right: 30),
padding: EdgeInsets.only(top: 5, bottom: 5, left: 20, right: 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50)
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Search hotel', style: TextStyle(color: Colors.grey)),
GestureDetector(
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ListPage()));
},
child: Container(
width: 30, height: 30,
child: Icon(Icons.search,color: Colors.white, size: 15),
decoration: BoxDecoration(
color: mainThemeColor,
borderRadius: BorderRadius.circular(25)
)
)
)
]
)
);
}
}
class ListPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Colors.white),
elevation: 0,
title: Icon(Icons.pool, color: Colors.white),
actions: [
Container(
margin: EdgeInsets.only(right: 15),
child: Icon(
Icons.notifications,
color: Colors.white
)
)
]
),
backgroundColor: mainThemeColor,
body: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(50),
topRight: Radius.circular(50)
),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(50),
topRight: Radius.circular(50)
)
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView.builder(
itemCount: attractionsList.length,
itemBuilder: (context, index) {
Attraction attr = attractionsList[index];
return AttractionCard(attraction: attr);
})
),
BottomBarWidget()
]
)
)
)
);
}
}
class AttractionCard extends StatelessWidget {
Attraction? attraction;
AttractionCard({ this.attraction });
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
blurRadius: 20,
offset: Offset.zero,
color: Colors.black.withOpacity(0.1)
)
]
),
child: ClipRRect(
borderRadius: BorderRadius.circular(40),
child: Container(
height: 300,
child: Stack(
children: [
Column(
children: [
Container(
height: 150,
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(attraction!.imgPath!),
fit: BoxFit.cover
)
)
),
Container(
height: 150,
padding: EdgeInsets.all(20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(attraction!.name!,
style: TextStyle(
color: Colors.black,
fontSize:14,
fontWeight:FontWeight.bold
)
),
SizedBox(width: 5),
Row(
children: [
Icon(
Icons.pin_drop,
color: Colors.grey.withOpacity(0.7),
size: 12
),
SizedBox(width: 5),
Text(attraction!.location!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7),
fontWeight: FontWeight.bold
)
),
]
),
SizedBox(width: 5),
RatingWidget(rating: attraction!.rating!)
]
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text('\$${attraction!.price!.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.black,
fontSize:16,
fontWeight: FontWeight.bold
)
),
SizedBox(height: 5),
Text('Per Night',
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7),
fontWeight: FontWeight.bold
)
)
],
)
]
)
)
],
),
Align(
alignment: Alignment.centerRight,
child: Container(
child: Icon(
Icons.favorite,
color: Colors.white,
size: 15
),
margin: EdgeInsets.only(right: 10),
width: 40,
height: 40,
decoration: BoxDecoration(
color: mainThemeColor,
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
blurRadius: 10,
color: mainThemeColor.withOpacity(0.5),
offset:Offset.zero
)
]
)
)
)
],
)
)
)
);
}
}
class BottomBarWidget extends StatefulWidget {
@override
BottomBarWidgetState createState() => BottomBarWidgetState();
}
class BottomBarWidgetState extends State<BottomBarWidget> {
List<BottomBarItem> barItems = barItemsList;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(top: 20, left: 20, right: 20, bottom: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
//... rest of the code omitted for brevity ...
children: List.generate(
barItems.length,
(index) {
var barItem = barItems[index];
return GestureDetector(
onTap: () {
setState(() {
barItems.forEach((element) {
element.isSelected = barItem == element;
});
});
},
child: Column(
children: [
Icon(barItem.icon, color: barItem.isSelected!
? mainThemeColor : Colors.grey),
Text(barItem.label!, style: TextStyle(
color: (barItem.isSelected! ? mainThemeColor : Colors.grey),
fontSize: 11
))
]
)
);
})
)
);
}
}
class RatingWidget extends StatelessWidget {
int? rating;
RatingWidget({ this.rating });
@override
Widget build(BuildContext context) {
return Row(
children: [
Row(
children: List.generate(5, (index) {
return Icon(
index < this.rating! ? Icons.star : Icons.star_border,
color: Colors.yellow
);
})
),
SizedBox(width: 5),
Text('${this.rating!}/5 Reviews',
style: TextStyle(
fontSize: 12,
color: Colors.grey.withOpacity(0.7)
)
)
],
);
}
}
class Attraction {
String? imgPath;
String? name;
String? desc;
double? price;
String? location;
int? rating;
Attraction({this.imgPath, this.name, this.desc, this.price, this.location, this.rating });
}
class BottomBarItem {
String? label;
bool? isSelected;
IconData? icon;
BottomBarItem({ this.label, this.isSelected, this.icon });
}