Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

7. Interactive Widgets
Written by Vincent Ngo

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In the previous chapter, you learned how to capture lots of data with scrollable widgets. But how do you make your app more engaging? How do you collect input and feedback from your users?

In this chapter, you’ll explore interactive widgets. In particular, you’ll learn to create:

  • Bottom Sheets widgets
  • Gesture-based widgets
  • Time and date picker widgets
  • Input and selection widgets
  • Dismissable widgets

You’ll continue to work on Yummy, building a more immersive experience. Users will be able to view menu items in detail, adjust quantities, manage and track order status.

You’ll start by enhancing the way users can view and select menu items for their cart.

Next, you’ll implement features for managing order details, including options for delivery or pickup and setting preferences for the date and time. Users will also be able to review their order summary and edit it before submission. Once an order is placed, they can track it in the Orders tab.

Additionally, you’ll ensure your app remains responsive in web mode, providing a seamless experience across different devices.

It’s time to get started.

Getting Started

Open the starter project in Android Studio and run flutter pub get, if necessary. Then, run the app. You’ll see the following:

New Project Files

There are new files in this starter project to help you out. Before you learn how to utilize interactive widgets, take a look at them.

New Packages

In pubspec.yaml under dependencies, there are two new packages:

New Files in the Models Folder

For your convenience two manager classes have been provided to manage state in your app:

Presenting Item Details

Before you display a specific menu item, you’ll need a way to present its widget.

Building a Bottom Sheet

Within lib/screens/restaurant_page.dart locate the comment // TODO: Show Bottom Sheet and replace it with the following:

// 1
void _showBottomSheet(Item item) {
  // 2
  showModalBottomSheet<void>(
    // 3
    isScrollControlled: true,
    // 4
    context: context,
    // 5
    constraints: const BoxConstraints(maxWidth: 480),
    // 6
    // TODO: Replace with Item Details Widget
    builder: (context) => Container(
      color: Colors.red,
      height: 400,
    ),
  );
}

Presenting the Bottom Sheet

Within the same file, find and replace // TODO: Replace _buildGridItem() and the whole _buildGridItem() function beneath it with the following:

Widget _buildGridItem(int index) {
  final item = widget.restaurant.items[index];
  return InkWell(
    onTap: () => _showBottomSheet(item),
    child: RestaurantItem(item: item),
  );
}

Building Item Details

When you tap on a specific menu item it shows a bottom sheet to focus on that specific item. Showing the title, popularity, description and enlarged image of the item.

import 'package:flutter/material.dart';
import '../models/cart_manager.dart';
import '../models/restaurant.dart';

class ItemDetails extends StatefulWidget {
  final Item item;
  final CartManager cartManager;
  final void Function() quantityUpdated;

  // 1
  const ItemDetails({
    super.key,
    required this.item,
    required this.cartManager,
    required this.quantityUpdated,
  });

  @override
  State<ItemDetails> createState() => _ItemDetailsState();
}

class _ItemDetailsState extends State<ItemDetails> {
  @override
  Widget build(BuildContext context) {
    // 2
    final textTheme = Theme.of(context)
        .textTheme
        .apply(displayColor: Theme.of(context).colorScheme.onSurface);
    // 3
    final colorTheme = Theme.of(context).colorScheme;

    // 4
    return Padding(
      padding: const EdgeInsets.all(16.0),
      // 5
      child: Wrap(
        children: [
          // 6
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                widget.item.name,
                style: textTheme.headlineMedium,
              ),
              // TODO: Add Liked Badge
              Text(widget.item.description),
              // TODO: Add Item Image
              // TODO: Add Cart Control
            ],
          ),
        ],
      ),
    );
  }

  // TODO: Create Most Liked Badge
  // TODO: Create Item Image
  // TODO: Create Cart Control

}

Showing Item Details

Return to restaurant_page.dart and locate the comment // TODO: Replace with Item Details Widget and replace it and the builder function with the following:

builder: (context) =>
ItemDetails(
  item: item,
  cartManager: widget.cartManager,
  quantityUpdated: () {
    setState(() {});
  },
),
import '../components/item_details.dart';

Creating a Most Liked Badge

Back in item_details.dart, locate the comment // TODO: Create Most Liked Badge and replace it with the following:

// 1
Widget _mostLikedBadge(ColorScheme colorTheme) {
  // 2
  return Align(
    // 3
    alignment: Alignment.centerLeft,
    // 4
    child: Container(
        padding: const EdgeInsets.all(4.0),
        color: colorTheme.onPrimary,
        // 5
        child: const Text('#1 Most Liked'),
      ),
  );
}
const SizedBox(height: 16.0),
_mostLikedBadge(colorTheme),
const SizedBox(height: 16.0),

Showing an Item Image

Locate the comment // TODO: Create Item Image and replace it with the following:

// 1
Widget _itemImage(String imageUrl) {
  // 2
  return Container(
    height: 200,
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(8.0),
      // 3
      image: DecorationImage(
        image: NetworkImage(imageUrl),
        fit: BoxFit.cover,
      ),
    ),
  );
}
const SizedBox(height: 16.0),
_itemImage(widget.item.imageUrl),
const SizedBox(height: 16.0),

Creating a Widget to Control the Cart

For the final piece of the item details view you’ll create a cart control component to update the quantity.

import 'package:flutter/material.dart';

// 1
class CartControl extends StatefulWidget {
  // 2
  final void Function(int) addToCart;

  const CartControl({
    required this.addToCart,
    super.key,
  });

  // 3
  @override
  State<CartControl> createState() => _CartControlState();
}

// 4
class _CartControlState extends State<CartControl> {
  // 5
  int _cartNumber = 1;

  @override
  Widget build(BuildContext context) {
    // 6
    final colorScheme = Theme.of(context).colorScheme;
    // 7
    return Row(
      // 8
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      // 9
      children: [
        // TODO: Add Cart Control Components
        Container(
          color: Colors.red,
          height: 44.0,
        ),
      ],
    );
  }

  // TODO: Build Minus Button
  // TODO: Build Cart Number
  // TODO: Build Plus Button
  // TODO: Build Add Cart Button
}

Creating the Minus Button

First you’ll create the minus button.

// 1
Widget _buildMinusButton() {
  // 2
  return IconButton(
    icon: const Icon(Icons.remove),
    // 3
    onPressed: () {
      setState(() {
        // 4
        if (_cartNumber > 1) {
          _cartNumber--;
        }
      });
    },
    // 5
    tooltip: 'Decrease Cart Count',
  );
}

Creating Cart Number Container

Next, you’ll add the container to display the quantity.

// 1
Widget _buildCartNumberContainer(ColorScheme colorScheme) {
  // 2
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
      color: colorScheme.onPrimary,
      // 3
      child: Text(_cartNumber.toString()),
    );
}

Creating the Plus Button

The next step is to add the plus button.

Widget _buildPlusButton() {
  return IconButton(
    icon: const Icon(Icons.add),
    onPressed: () {
      setState(() {
        _cartNumber++;
      });
    },
    tooltip: 'Increase Cart Count',
  );
}

Creating the Add to Cart Button

The final component you’ll add is the Add to Cart button.

Widget _buildAddCartButton() {
  // 1
  return FilledButton(
    // 2
    onPressed: () {
      widget.addToCart(_cartNumber);
    },
    // 3
    child: const Text('Add to Cart'),
  );
}

Showing the Cart Control Components

Now that you’ve built all the components, it’s time to put them to use.

_buildMinusButton(),
_buildCartNumberContainer(colorScheme),
_buildPlusButton(),
const Spacer(),
_buildAddCartButton(),

Using the Cart Control

You’ll now add the cart control to the item details view.

// 1
Widget _addToCartControl(Item item) {
  // 2
  return CartControl(
    // 3
    addToCart: (number) {
      const uuid = Uuid();
      final uniqueId = uuid.v4();
      final cartItem = CartItem(
          id: uniqueId,
          name: item.name,
          price: item.price,
          quantity: number,
        );
      // 4
      setState(() {
        widget.cartManager.addItem(cartItem);
        // 5
        widget.quantityUpdated();
      });
      // 6
      Navigator.pop(context);
    },
  );
}
import 'package:uuid/uuid.dart';
import 'cart_control.dart';

Applying the Cart Control

Locate the comment // TODO: Add Cart Control and replace it with the following:

_addToCartControl(widget.item),

Building the Checkout Page

In this next section, you’ll learn about how to create drawers and leverage input widgets to capture data.

Adding a Drawer

Drawers are commonly used for secondary navigation options.

static const double drawerWidth = 375.0;
Widget _buildEndDrawer() {
  return SizedBox(
    width: drawerWidth,
    // TODO: Replace with Checkout Page
    child: Container(color: Colors.red),
  );
}
endDrawer: _buildEndDrawer(),

Adding a Floating Action Button

You’ll use a floating action button when clicked on to present the drawer.

Opening the Drawer

Locate the comment // TODO: Define Scaffold Key and replace it with the following:

final GlobalKey<ScaffoldState> scaffoldKey = GlobalKey<ScaffoldState>();
key: scaffoldKey,
void openDrawer() {
  scaffoldKey.currentState!.openEndDrawer();
}

Add a Floating Action Button

Now you need to create the floating action button to open the drawer.

// 1
Widget _buildFloatingActionButton() {
  // 2
  return FloatingActionButton.extended(
    // 3
    onPressed: openDrawer,
    // 4
    tooltip: 'Cart',
    // 5
    icon: const Icon(Icons.shopping_cart),
    // 6
    label: Text('${widget.cartManager.items.length} Items in cart'),
  );
}
floatingActionButton: _buildFloatingActionButton(),

Creating the Checkout Page

Within the lib/screens directory, create a new file called checkout_page.dart and add the following code:

// 1
import 'package:flutter/material.dart';
import '../models/cart_manager.dart';
import '../models/order_manager.dart';

class CheckoutPage extends StatefulWidget {
  // 2
  final CartManager cartManager;
  // 3
  final Function() didUpdate;
  // 4
  final Function(Order) onSubmit;

  const CheckoutPage(
      {super.key,
      required this.cartManager,
      required this.didUpdate,
      required this.onSubmit,
    });

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  // 5
  // TODO: Add State Properties
  // TODO: Configure Date Format
  // TODO: Configure Time of Day
  // TODO: Set Selected Segment
  // TODO: Build Segmented Control
  // TODO: Build Name Textfield
  // TODO: Select Date Picker
  // TODO: Select Time Picker
  // TODO: Build Order Summary
  // TODO: Build Submit Order Button

  @override
  Widget build(BuildContext context) {
    // 6
    final textTheme = Theme.of(context)
        .textTheme
        .apply(displayColor: Theme.of(context).colorScheme.onSurface);

    // 7
    return Scaffold(
      // 8
      appBar: AppBar(
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => Navigator.of(context).pop(),
        ),
      ),
      // 9
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Text(
              'Order Details',
              style: textTheme.headlineSmall,
            ),
            // TODO: Add Segmented Control
            // TODO: Add Name Textfield
            // TODO: Add Date and Time Picker
            // TODO: Add Order Summary
            // TODO: Add Submit Order Button
          ],
        ),
      ),
    );
  }
}

Using the Checkout Page

Back in restaurant_page.dart locate the comment // TODO: Replace with Checkout Page and replace it and the child beneath it with the following:

// 1
child: Drawer(
  // 2
  child: CheckoutPage(
    // 3
    cartManager: widget.cartManager,
    // 4
    didUpdate: () {
      setState(() {});
    },
    // 5
    onSubmit: (order) {
      widget.ordersManager.addOrder(order);
      Navigator.popUntil(context, (route) => route.isFirst);
    },
  ),
),
import 'checkout_page.dart';

Adding Checkout State Properties

Back in checkout_page.dart, locate // TODO: Add State Properties and replace it with the following:

// 1
final Map<int, Widget> myTabs = const <int, Widget>{
  0: Text('Delivery'),
  1: Text('Self Pick-Up'),
};
// 2
Set<int> selectedSegment = {0};
// 3
TimeOfDay? selectedTime;
// 4
DateTime? selectedDate;
// 5
final DateTime _firstDate = DateTime(DateTime.now().year - 2);
final DateTime _lastDate = DateTime(DateTime.now().year + 1);
// 6
final TextEditingController _nameController = TextEditingController();

Adding a Segmented Control

The first widget you will build is a segmented control. This is a way for users to toggle between food delivery or pick-up.

void onSegmentSelected(Set<int> segmentIndex) {
  setState(() {
    selectedSegment = segmentIndex;
  });
}
Widget _buildOrderSegmentedType() {
  // 1
  return SegmentedButton(
    // 2
    showSelectedIcon: false,
    // 3
    segments: const [
      ButtonSegment(
          value: 0,
          label: Text('Delivery'),
          icon: Icon(Icons.pedal_bike),
        ),
      ButtonSegment(
          value: 1,
          label: Text('Pickup'),
          icon: Icon(Icons.local_mall),
        ),
    ],
    // 4
    selected: selectedSegment,
    // 5
    onSelectionChanged: onSegmentSelected,
  );
}
const SizedBox(height: 16.0),
_buildOrderSegmentedType(),

Adding a Textfield to Enter the Customer Name

You’ll now need a way to gather the customer’s name. This will help the restaurant or the delivery team to know how to address the recipient.

Widget _buildTextField() {
  // 1
  return TextField(
    // 2
    controller: _nameController,
    // 3
    decoration: const InputDecoration(
      labelText: 'Contact Name',
    ),
  );
}
const SizedBox(height: 16.0),
_buildTextField(),

Creating a Date Picker

Now you’ll need a way for the user to select the date to pick up or have the food delivered.

// 1
String formatDate(DateTime? dateTime) {
  // 2
  if (dateTime == null) {
    return 'Select Date';
  }
  // 3
  final formatter = DateFormat('yyyy-MM-dd');
  return formatter.format(dateTime);
}
import 'package:intl/intl.dart';
// 1
void _selectDate(BuildContext context) async {
  // 2
  final picked = await showDatePicker(
    // 3
    context: context,
    // 4
    initialDate: selectedDate ?? DateTime.now(),
    // 5
    firstDate: _firstDate,
    lastDate: _lastDate,
  );
  // 6
  if (picked != null && picked != selectedDate) {
    setState(() {
      selectedDate = picked;
    });
  }
}

Creating a Time Picker

Here’s how your time picker will look like.

// 1
String formatTimeOfDay(TimeOfDay? timeOfDay) {
  // 2
  if (timeOfDay == null) {
    return 'Select Time';
  }
  // 3
  final hour = timeOfDay.hour.toString().padLeft(2, '0');
  final minute = timeOfDay.minute.toString().padLeft(2, '0');
  return '$hour:$minute';
}
// 1
void _selectTime(BuildContext context) async {
  // 2
  final picked = await showTimePicker(
    // 3
    context: context,
    // 4
    initialEntryMode: TimePickerEntryMode.input,
    //  5
    initialTime: selectedTime ?? TimeOfDay.now(),
    // 6
    builder: (context, child) {
      return MediaQuery(
        data: MediaQuery.of(context).copyWith(
          alwaysUse24HourFormat: true,
        ),
        child: child!,
      );
    },
  );
  // 7
  if (picked != null && picked != selectedTime) {
    setState(() {
      selectedTime = picked;
    });
  }
}

Showing the Date and Time Pickers

Replace // TODO: Add Date and Time Picker with:

// 1
const SizedBox(height: 16.0),
// 2
Row(
  children: [
    TextButton(
      // 3
      child: Text(formatDate(selectedDate)),
      // 4
      onPressed: () => _selectDate(context),
    ),
    TextButton(
      // 5
      child: Text(formatTimeOfDay(selectedTime)),
      // 6
      onPressed: () => _selectTime(context),
    ),
  ],
),
// 7
const SizedBox(height: 16.0),

Creating Order Summary

Now you’ll create a way to display the list of items the user selected.

// 1
Widget _buildOrderSummary(BuildContext context) {
  // 2
  final colorTheme = Theme.of(context).colorScheme;

  // 3
  return Expanded(
    // 4
    child: ListView.builder(
      // 5
      itemCount: widget.cartManager.items.length,
      itemBuilder: (context, index) {
        // 6
        final item = widget.cartManager.itemAt(index);
        // 7
        // TODO: Wrap in a Dismissible Widget
        return ListTile(
          leading: Container(
            padding: const EdgeInsets.all(8.0),
            decoration: BoxDecoration(
              borderRadius: const BorderRadius.all(
                Radius.circular(8.0)),
              border: Border.all(
                color: colorTheme.primary,
                width: 2.0,
              ),
            ),
            child: ClipRRect(
              borderRadius: const BorderRadius.all(
                Radius.circular(8.0)),
              child: Text('x${item.quantity}'),
            ),
          ),
          title: Text(item.name),
          subtitle: Text('Price: \$${item.price}'),
        );
      },
    ),
  );
}
const Text('Order Summary'),
_buildOrderSummary(context),

Deleting an Item From an Order

The user will swipe left to remove a menu item.

// 1
key: Key(item.id),
// 2
direction: DismissDirection.endToStart,
// 3
background: Container(),
// 4
secondaryBackground: const SizedBox(
  child: Row(
    mainAxisAlignment: MainAxisAlignment.end,
    children: [
      Icon(Icons.delete),
    ],
  ),
),
// 5
onDismissed: (direction) {
  setState(() {
    widget.cartManager.removeItem(item.id);
  });
  // 6
  widget.didUpdate();
},

Widget _buildSubmitButton() {
  // 1
  return ElevatedButton(
    // 2
    onPressed: widget.cartManager.isEmpty
        ? null
        // 3
        : () {
            final selectedSegment = this.selectedSegment;
            final selectedTime = this.selectedTime;
            final selectedDate = this.selectedDate;
            final name = _nameController.text;
            final items = widget.cartManager.items;
            // 4
            final order = Order(
              selectedSegment: selectedSegment,
              selectedTime: selectedTime,
              selectedDate: selectedDate,
              name: name,
              items: items,
            );
            // 5
            widget.cartManager.resetCart();
            // 6
            widget.onSubmit(order);
          },
    child: Padding(
      padding: const EdgeInsets.all(16.0),
      // 7
      child: Text(
        '''Submit Order - \$${widget.cartManager.totalCost.toStringAsFixed(2)}'''),
    ),
  );
}
_buildSubmitButton(),

Building the Orders Page

When someone places an order they likely want to see the list of orders they’ve placed. When you’re done with this section the Orders tab will look like this:

import 'package:flutter/material.dart';
import '../models/order_manager.dart';

class MyOrdersPage extends StatelessWidget {
  final OrderManager orderManager;

  // 1
  const MyOrdersPage({
    super.key,
    required this.orderManager,
  });

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context)
      .textTheme
      .apply(displayColor: Theme.of(context).colorScheme.onSurface);
    // 2
    return Scaffold(
      appBar: AppBar(
        centerTitle: false,
        title: Text('My Orders', style: textTheme.headlineMedium),
      ),
      // 3
      body: ListView.builder(
        // 4
        itemCount: orderManager.totalOrders,
        itemBuilder: (context, index) {
          // 5
          return OrderTile(order: orderManager.orders[index]);
        },
      ),
    );
  }
}

// 6
class OrderTile extends StatelessWidget {
  final Order order;

  const OrderTile({super.key, required this.order});

  @override
  Widget build(BuildContext context) {
    final textTheme = Theme.of(context)
      .textTheme
      .apply(displayColor: Theme.of(context).colorScheme.onSurface);

    // 7
    return ListTile(
      leading: ClipRRect(
        borderRadius: BorderRadius.circular(8.0),
        // 8
        child: Image.asset(
          'assets/food/burger.webp',
          width: 50.0,
          height: 50.0,
          fit: BoxFit.cover,
        ),
      ),
      // 9
      title: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 10
          Text(
            'Scheduled',
            style: textTheme.bodyLarge,
          ),
          // 11
          Text(order.getFormattedOrderInfo()),
          // 12
          Text('Items: ${order.items.length}'),
        ],
      ),
    );
  }
}

Showing the Orders Page

Open home.dart and locate // TODO: Replace with Order Page and replace it and the Center code beneath it with the following:

MyOrdersPage(orderManager: widget.ordersManager),
import 'screens/myorders_page.dart';

Key Points

  • You can pass data around with callbacks
  • You can use callbacks also to pass data one level up.
  • Manager objects help you manage functions and state changes in one place.
  • TextEditingController is used to listen for changes in a TextField widget.
  • Split your widgets by screen to keep your code modular and organized.
  • Gesture widgets recognize and determine the type of touch event. They provide callbacks to react to events like onTap() or onDrag().
  • You can use dismissible widgets to swipe away items in a list.

Where to Go From Here?

There are many ways to engage and collect data from your users. You’ve learned to pass data around using callbacks. You learned to create different input widgets. You also learned to apply touch events to navigate to parts of your app.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now