What You'll Build in this Workshop:

Full Application

We'll be creating the details page for when a user selects one of the items in the AttractionListView we created on the previous lab.

Here's the schematic view of the details page we'll be tackling:

Details Page

Note: For the duration of the development of this page, replace the home property of the MaterialApp page by the DetailsPage widget we will be building (instead of the LandingPage), as such:

Details Page

With that out of the way, let's start by creating the placeholder / boilerplate code for creating a custom widget: a class that extends StatelessWidget. Grab the boilerplate code below to proceed:

class DetailsPage extends StatelessWidget {
 
  @override 
  Widget build(BuildContext context) {
    return Scaffold(
      body: Text('Details Page')
    );
  }
}

If you take it for a spin on DartPad, you should see the following output:

Details Page

Refactor the class's constructor

For this class (DetailsPage) let's add the property through which the user will supply the value via the constructor. Create a property called selectedModel, type AttractionModel. Modify the constructor to accommodate this change:

Details Page

Building the Scaffold of the Details Page

Proceed by adding replacing the body of the Scaffold widget (currently a sample Text widget) by a Stack widget. Remember we will be laying out the items in a layered fashion.

Details Page

Let's add some children to this Stack widget!

As the first child in the Stack widget, let's add a Container, which we'll use for the background image, as before. Notice how we are pulling the imgPath from the selectedModel property to populate the NetworkImage.

// add as the first child in the Stack:

Container(
    decoration: BoxDecoration(
    image: DecorationImage(
        image: NetworkImage(selectedModel!.imgPath!),
        fit: BoxFit.cover
    )
    )
),

Now, add the gradient layer, which is the one above the image. As before, we are using a Container and setting its gradient property out of the decoration property:

Container(
    decoration: BoxDecoration(
        gradient: LinearGradient(
            colors: [
            Colors.transparent,
            Colors.black.withOpacity(0.8)
            ],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter
        )
    )
)

Next up, the Column widget holding the Text widgets displaying the full information. Apply left align (CrossAxisAlignment.start) and bottom alignment (MainAxisAlignment.end) on the Column widget. Add SizedBox widgets for spacing:

// add this as the third item in the Stack

Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    mainAxisAlignment: MainAxisAlignment.end,
        children: [
            Text(selectedModel!.name!, style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
            Text(selectedModel!.location!, style: TextStyle(color: mainYellow)),
            SizedBox(height: 20),
            Text(selectedModel!.description!, style: TextStyle(color: Colors.white.withOpacity(0.7))),
            SizedBox(height: 40)
        }
    }
)

Let's take it for a spin on DartPad to see how's coming along:

Details Page

So far so good! See how we were able to achieve this type of layout by leveraging stacks. Let's continue diving into this structure. Inside of the Column widget (last widget in the Stack) let's add the structure that corresponds to the bottom button panel that shows in the design.

Add a Row widget, with its mainAxisAlignment set to MainAxisAlignment.spaceBetween as we want each of the children of the Row to be separated by a space dictated by the parent based on the space available and the space occupied by its children.

Details Page

Inside of this Row widget, we'll add the two buttons corresponding to this panel.

Start by adding a TextButton with a child Text widget, color white. Since the onPressed event is required for TextButton widgets, supply an empty callback for now:

Details Page

Now add a custom button for the right hand side of the Row widget. We'll build this button by creating the following structure:

Here's the structure built for this custom button:

ClipRRect(
    borderRadius: BorderRadius.circular(20),
    child: Material(
        color: mainYellow,
        child: InkWell(
            onTap: () {},
            splashColor: Colors.black.withOpacity(0.1),
            highlightColor: Colors.black.withOpacity(0.2),
            child: Container(
                padding: EdgeInsets.only(top: 10, bottom: 10, left: 20, right: 20),
                child: Text('Use Itinerary', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold))
            ),
        ),
    )
)

If you run DartPad with the changes made above, you should see the following:

Details Page

Try tapping on the yellow button on the right - notice the ink well effect, provided by both the Material and InkWell design.

We are missing some spacing around those components. Let's add it surrounding the whole Column widget containing all the content inside a Padding widget, with a 30px padding all around:

Details Page

With that small change, see the difference by hitting Run on DartPad and notice the spacing around the content - much better!:

Details Page

Let's continue by adding the AppBar widget.

Adding the AppBar on top of the Stack

Add an AppBar widget inside the Stack widget, all the way at the bottom.

Yes - you might be wondering "why are we adding the AppBar as a child to the Stack as opposed to where it usually goes - as the appBar of the Scaffold widget?". Well, adding it to the Scaffold will also add it at the top of the screen, but will devote a fixed space at the top of the screen and won't allow items to overlap it or show through.

Add an AppBar widget as a child of the Stack widget, with the following specs:

Here's the code for ease of typing:

// this code goes inside the AppBar
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: mainYellow),

Details Page

As in the AppBar widget of the LandingPage page widget, we'll set its title property to be an Icon widget, wrapped inside a Center widget, as follows:

Details Page

We'll add a mocked action item to the AppBar widget (no functionality, just a placeholder), just to show you how you can utilize the AppBar widget's actions property.

We'll add a Container widget with 10px of right margin, wrapping an IconButton widget, color mainYellow.

Below the whole code for the AppBar to go on the Stack widget:

AppBar(
    elevation: 0,
    backgroundColor: Colors.transparent,
    iconTheme: IconThemeData(color: mainYellow),
    title: Center(
        child: Icon(Icons.airplanemode_on, color: mainYellow)
    ),
    actions: [
        Container(
            margin: EdgeInsets.only(right: 10),
            child: IconButton(
                icon: Icon(Icons.favorite, color: mainYellow),
                onPressed: () {}
            )
        )
    ]
)

Running it on DartPad at this point, you should see something similar to the illustration below:

Details Page

If you've made it this far, that means you've completed both labs and now we are really to connect our screens via a navigation strategy, as illustrated below:

Details Page

Let's make it so that when a user selects an item in the AttractionListView, it navigates to the Details page, showing the details of the same attraction the user selected.

Let's go back to the AttractionCard widget, and do a minor refactor there.

In the build method, wrap the returned Container widget into a GestureDetector widget. The GestureDetector widget provides capabilities for adding gestures to widgets.

Details Page

The GestureDetector provides several events, among them the onTap event, which is what we want. When a user taps on one of the AttractionCard widgets, we want to direct users them to DetailsPage, passing the context to the next page via a MaterialPageRoute:

Here's the code for the onTap event so you can just drop it into the GestureDetector:

// add this lines inside the GestureDerector

onTap: () {
    Navigator.of(context).push(
        MaterialPageRoute(builder: (context) => DetailsPage(selectedModel: attractionModel,))
    );
},

Details Page

Now, go back to the MaterialApp widget and make the home property point back to the LandingPage widget.

Running it one last time by hitting Run on DartPad, and it will produce the full-fledged app! You should notice how the app starts from the LandingPage, and tapping on one of the cards takes you to the DetailsPage, it passes the correct context, which gets displayed accurately there.

While taking it for a spin, ensure that the following are taking place:

Congrats on completing this bonus Flutter codelab where we touch on the following:

Hope you've enjoyed these codelabs as much as I had putting them together.

Please don't forget to follow me on social media:

Thanks for joining me on this Flutter journey! Cheers!!!!

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';

final Color mainYellow = Color(0xFFFFB02F);
final Color primaryGray = Color(0xFF313131);
final Color secondaryGray = Color(0xFF1C1C1C);
final Color lightGray = Color(0xFF3B3B3B);

final List<AttractionModel> attractions = [
  AttractionModel(
    imgPath: 'https://images.pexels.com/photos/260590/pexels-photo-260590.jpeg?auto=compress&cs=tinysrgb&dpr=3&h=750&w=1260',
    name: 'Golden Gate Bridge',
    location: 'San Francisco, CA',
    description: 'The Golden Gate Bridge is a suspension bridge spanning the Golden Gate, the one-mile-wide strait connecting San Francisco Bay and the Pacific Ocean.'
  ),
  AttractionModel(
    imgPath: 'https://images.pexels.com/photos/5627275/pexels-photo-5627275.jpeg?auto=compress&cs=tinysrgb&dpr=3&h=750&w=1260',
    name: 'Brooklyn Bridge',
    location: 'Brooklyn, NY',
    description: 'The Golden Gate Bridge is a suspension bridge spanning the Golden Gate, the one-mile-wide strait connecting San Francisco Bay and the Pacific Ocean.'
  ),
  AttractionModel(
    imgPath: 'https://images.pexels.com/photos/5241381/pexels-photo-5241381.jpeg?auto=compress&cs=tinysrgb&dpr=3&h=750&w=1260',
    name: 'London Bridge',
    location: 'London, UK',
    description: 'The Golden Gate Bridge is a suspension bridge spanning the Golden Gate, the one-mile-wide strait connecting San Francisco Bay and the Pacific Ocean.'
  ),
  AttractionModel(
    imgPath: 'https://images.pexels.com/photos/1680247/pexels-photo-1680247.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=650&w=940',
    name: 'Harbour Bridge',
    location: 'Sydney, AU',
    description: 'The Golden Gate Bridge is a suspension bridge spanning the Golden Gate, the one-mile-wide strait connecting San Francisco Bay and the Pacific Ocean.'
  )
];

final List<BottomBarModel> bottomBarListItems = [
  BottomBarModel(icon: Icons.explore_outlined, isSelected: true),
  BottomBarModel(icon: Icons.favorite_border, isSelected: false),
  BottomBarModel(icon: Icons.comment_outlined, isSelected: false),
  BottomBarModel(icon: Icons.account_circle_outlined, isSelected: false),
];

void main() {
  runApp(
    MaterialApp(
      home: LandingPage(),
      debugShowCheckedModeBanner: false
    )
  );
}


class LandingPage extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        backgroundColor: primaryGray,
        iconTheme: IconThemeData(color: mainYellow),
        title: Center(
          child: Icon(Icons.airplanemode_on, color: mainYellow)
        ),
        actions: [
          Container(
            margin: EdgeInsets.only(right: 10),
            child: IconButton(
              icon: Icon(Icons.notifications_on_outlined, color: Colors.grey),
              onPressed: () {}
            )
          )
        ]
      ),
      drawer: Drawer(
        child: Container(
          color: mainYellow,
          alignment: Alignment.bottomLeft,
          padding: EdgeInsets.all(20),
          child: Icon(Icons.airplanemode_on, size: 80, color: Colors.black)
        )
      ),
      body: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [
              primaryGray,
              secondaryGray
            ],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter
          )
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            HeaderWidget(),
            AttractionListView(),
            BottomBarWidget()
          ]
        )
      )
    );
  }
}

//-----WIDGETS-----

class HeaderWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Padding(
          padding: EdgeInsets.only(top: 30, left: 30, right: 30),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('Where do you', style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
              Text('want to go?', style: TextStyle(color: mainYellow, fontSize: 30, fontWeight: FontWeight.bold))
            ]
          ),
        ),
        Padding(
          padding: EdgeInsets.only(left: 30, right: 30),
          child: Container(
            padding: EdgeInsets.all(15),
            decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(50),
              color: lightGray
            ),
            margin: EdgeInsets.only(top: 20, bottom: 20),
            child: Row(
              children: [
                Icon(Icons.search, color: Colors.grey),
                SizedBox(width: 10),
                Text('Search', style: TextStyle(color: Colors.grey))
              ]
            )
          )
        )
      ]
    );
  }
}

class AttractionListView extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: ListView.builder(
        padding: EdgeInsets.only(left: 10),
        itemCount: attractions.length,
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) {
          
          AttractionModel currentAttraction = attractions[index];
          return AttractionCard(attractionModel: currentAttraction);

        }
      )
    );
  }
}

class AttractionCard extends StatelessWidget {

  AttractionModel? attractionModel;

  AttractionCard({ this.attractionModel });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
        onTap: () {
          Navigator.of(context).push(
            MaterialPageRoute(builder: (context) => DetailsPage(selectedModel: attractionModel,))
          );
        },
        child: Container(
          width: 180,
          margin: EdgeInsets.all(10),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(25),
            child: Stack(
              children: [
                Container(
                  decoration: BoxDecoration(
                    image: DecorationImage(
                      image: NetworkImage(attractionModel!.imgPath!),
                      fit: BoxFit.cover
                    )
                  )
                ),
                Container(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      colors: [
                        Colors.transparent,
                        Colors.black.withOpacity(0.5)
                      ],
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter
                    )
                  )
                ),
                Padding(
                  padding: EdgeInsets.all(30),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisAlignment: MainAxisAlignment.end,
                    children: [
                      Text(attractionModel!.name!, style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
                      SizedBox(height: 10),
                      Text(attractionModel!.location!, style: TextStyle(color: mainYellow))
                    ]
                  )
                )
              ]
            ),
          )
        )
    );
  }
}

class BottomBarWidget extends StatefulWidget {
  @override
  State<BottomBarWidget> createState() => BottomBarWidgetState();
}

class BottomBarWidgetState extends State<BottomBarWidget> {

  List<BottomBarModel> _bottomBarItems = bottomBarListItems;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.only(top: 20, bottom: 20),
      padding: EdgeInsets.all(20),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: List.generate(
          _bottomBarItems.length, (index) {

            var barItemWidget = _bottomBarItems[index];

            return IconButton(
              icon: Icon(
                barItemWidget.icon, 
                color: barItemWidget.isSelected! ? mainYellow : Colors.grey
              ),
              onPressed: () {
                setState(() {
                  _bottomBarItems.forEach((element) {
                    element.isSelected = element == barItemWidget;
                  });
                });
              }
            );
          }
        )
      )
    );
  }
}

class DetailsPage extends StatelessWidget {
 
  AttractionModel? selectedModel;
  
  DetailsPage({ this.selectedModel });

  @override 
  Widget build(BuildContext context) {
    return Scaffold(
      body:  Stack(
        children: [
          Container(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: NetworkImage(selectedModel!.imgPath!),
                  fit: BoxFit.cover
                )
              )
            ),
          Container(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [
                    Colors.transparent,
                    Colors.black.withOpacity(0.8)
                  ],
                  begin: Alignment.topCenter,
                  end: Alignment.bottomCenter
                )
              )
            ),
          Padding(
              padding: EdgeInsets.all(30),
              child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              mainAxisAlignment: MainAxisAlignment.end,
                children: [
                  Text(selectedModel!.name!, style: TextStyle(color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
                  Text(selectedModel!.location!, style: TextStyle(color: mainYellow)),
                  SizedBox(height: 20),
                  Text(selectedModel!.description!, style: TextStyle(color: Colors.white.withOpacity(0.7))),
                  SizedBox(height: 40),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      TextButton(
                          child: Text('View Comments', style: TextStyle(color: Colors.white)),
                          onPressed: () {}
                        ),
                        ClipRRect(
                          borderRadius: BorderRadius.circular(20),
                          child: Material(
                            color: mainYellow,
                            child: InkWell(
                              onTap: () {},
                              splashColor: Colors.black.withOpacity(0.1),
                              highlightColor: Colors.black.withOpacity(0.2),
                              child: Container(
                                padding: EdgeInsets.only(top: 10, bottom: 10, left: 20, right: 20),
                                child: Text('Use Itinerary', style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold))
                              ),
                            ),
                          )
                        )
                    ]
                  )
                ]
            )
          ),
          AppBar(
            elevation: 0,
            backgroundColor: Colors.transparent,
            iconTheme: IconThemeData(color: mainYellow),
            title: Center(
              child: Icon(Icons.airplanemode_on, color: mainYellow)
            ),
            actions: [
              Container(
                margin: EdgeInsets.only(right: 10),
                child: IconButton(
                  icon: Icon(Icons.favorite, color: mainYellow),
                  onPressed: () {}
                )
              )
            ]
          )
        ]
      )
    );
  }
}

//-----MODELS-----

class AttractionModel {
  
  String? imgPath;
  String? name;
  String? location;
  String? description;
  
  AttractionModel({
    this.imgPath,
    this.name,
    this.location,
    this.description
  });
}

class BottomBarModel {
  IconData? icon;
  bool? isSelected;

  BottomBarModel({ this.icon, this.isSelected });
}