Logo
Back

Make a food delivery app with DollarBackend + Flutter (3/3)

We're adding a cart to our food delivery app for iOS/Android, using Flutter to develop the app and Strapi 4 as a no code backend.

So far we've written a delivery app's menu list and a details page.

Today we're writing the last part of our mobile app: a cart.
In this tutorial we'll learn how to store data in memory
using Dart's Singleton pattern and how to create an entry in Strapi with a POST request.

Create a shopping cart repository

Previously we have created a repositories folder, which keeps a list of repositories.
A repository contains data retrieving logic, such as how to get some data from the server. As of now, we have 1 repository: MenuItemsRepository, which keeps the logic
of interacting with Strapi.

On top of that, we need to keep a list of items added to cart. Let's create a new file in repositories called cart.dart.

For this demo, we'll create a singleton cart. A singleton is a design pattern
where the object (CartRepository in this case) is created once and then shared
throughout the app.

For our use case, we need to access the cart in 2 places:

  1. on /details-page to add an item to the cart
  2. on /order-page to view all items that were added to the cart

It wouldn't be nice if a user added an item on a salad details page,
the cart would be deleted after DetailsPage widget is destroyed and a new, empty,
cart then created on the OrderPage.

Having CartRepository as a singleton means that only
one cart can exist throughout the app, resolving this issue.

Here's the code for cart.dart:

import '../domain/menu.dart';

class CartRepository {
  // global singleton instance
  static final CartRepository _cart = CartRepository._create();

  // a list of items added to cart
  final List<MenuItemData> _items = List.empty(growable: true);

  // a getter for `_items`
  // we don't want to expose `_items` as a public variable,
  // to prevent people from changing it
  List<MenuItemData> get items => _items;

  // a factory for `CartRepository()` constructor
  //
  // creating a new instance using this factory
  // is like this:
  //  final _cartRepository = CartRepository();s
  factory CartRepository() {
    // have to return an existing instance to preserve
    // all the variables
    return CartRepository._cart;
  }

  // a private constructor. it could be called anything,
  // here it is called _create
  CartRepository._create();

  /// Add [MenuItemData] to [list]
  add(MenuItemData item) {
    _items.add(item);
  }

  /// Remove all [MenuItemData]s from the cart
  clear() {
    _items.clear();
  }
}

The code above looks a bit weird, so let's dig right in. We create a new class
called MenuItemData.

After that, there's static final CartRepository _cart.

static variables in Dart are variables that are not attached to a specific class instance. They're useful for class-wide variables and constants. final is like
const in JavaScript: once an object was created, it cannot be reassigned. By having static final _cart we tell Dart that _cart cannot be reassigned and that _cart should be the same throughout all CartRepository instances.

Then we need to overwrite a default constructor. We can use dart's factory keyword
for that. factory CartRepository() returns the _cart variable.

After overwriting the default constructor, there isn't a way to create any new class instances. We have to create one instance to return it from our constructor, though.

That's why we create a new private constructor CartRepository._create();. Everything
prefixed with _ is private in Dart (kind of similar to Python). _create is
a private constructor we can use to create a new instance of CardRepository.

Then we can initialize a state.
final List _items will keep a list of shopping cart items.
List get items is a getter, to prevent code outside of the class from overwriting
_items.

Then we have two class methods: add (to add an item to the cart) and clear (to clear the cart).

Adding the UI

"Add to cart" button

First of all, we need to create "Add to cart" button. We'll create it in the DetailsPage widget.

class _DetailsPageState extends State<DetailsPage> {
    final _cartRepository = CartRepository();

    @override
    Widget build(BuildContext context) {
        return Scaffold(
            // ...
            floatingActionButton: FloatingActionButton(
              onPressed: () {
                var i = _item;
                if (i != null) {
                  _cartRepository.add(i);
                }
              },
              tooltip: "Add to cart",
              child: const Icon(Icons.add),
            ),
          );
        }
    }
}

First, initialize a new CartRepository:

Even though it looks like we're creating a new empty instance of CartRepository, we're not (we've just overwrote a constructor).

An "Add to cart" button will be a FloatingActionButton (sometimes abbreviated as FAB). To create a new floating action button in Flutter, pass an instance of FloatingActionButton as Scaffold's floatingActionButton parameter
(floatingActionButton could be any widget, but in this instance FloatingActionButton is most appropriate).

Pass an Add icon as a child to FloatingActionButton and don't forget the tooltip parameter.
If a user doesn't understand the icon, they can see the tooltip by tapping and holding the button; tooltip can also be read by screen readers, making the app more accessible.

Finally, call _cartRepository.add(item); in the onPressed callback.

Now the user can add stuff to the cart.

Getting to the order screen

Let's add another floating action button to the HomePage:

class _HomePageState extends State<HomePage> {
 @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          AutoRouter.of(context).push(const OrderRoute());
        },
        tooltip: "Order",
        backgroundColor: kOrderButtonBgColor,
        foregroundColor: kOrderButtonFgColor,
        child: const Icon(Icons.shopping_cart),
      ),
    );
  }
}

Add a FloatingActionButton to HomeWidget's Scaffold. When onPressed is called,
OrderRoute is pushed to the navigation stack with AutoRouter.of(context).

Oh, and I have added backgroundColor and foregroundColor to that FloatingActionButton to make it look a bit more different from the rest of the UI.

Add the order page

So far so good! We need to show a cart to the user next.

To do that, let's create a simple order page layout:

class _OrderPageState extends State<OrderPage> {
  final _cartRepository = CartRepository();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("Place an order")),
      body: ListView(
        children: [
          Text("Order"),
          ..._buildCart(),
          ElevatedButton(
            onPressed: () {},
            child: const Text("Order"),
          ),
          TextButton(
            onPressed: () {
                setState(() {
                    _cartRepository.clear();
                });
            },
            child: const Text("Clear cart"),
          )
        ],
      ),
    );
  }
}

OrderPage must be a stateful widget to keep _cartRepository in its state.

Then we have a page title, "Order", _buildCart method will return all cart items,
and after that there are two buttons: "Clear cart" (which calls _cartRepository.clear())
and "Order", which will send an order to a server.

List<Widget> _buildCart() {
  var items = _cartRepository.items;

  if (items.isEmpty) {
    return [Text("No items in cart")];
  }

  double total = 0;
  List<Widget> itemsList = [];

  itemsList.addAll(items.map(
    (i) {
      total += i.price;

      return ListTile(
        leading: const Icon(Icons.restaurant),
        title: Text(i.name),
        subtitle: Text("\$ ${i.price}"),
      );
    },
  ));

  itemsList.add(Text("Total: \$ $total"));

  return itemsList;
}

_buildCart will return a "No items found" text if the cart is empty.
Otherwise, it will return a list of ListTiles with item names and prices.

A variable total keeps track of the total price. map method returns an
Iterable, inside which we can sum the prices of all items.
Finally, a Text widget shows the total price to the user.

Basic logic is ready. Now we need to send the order to Strapi.

Create a collection type Order

We need a new collection type to save the orders in. Let's create it

Create an "Order" collection type with "text name", "text phone", "JSON data" and "boolean done" fields

It has 3 fields:

  • name — text, required, customer's name
  • phone — text, required, customer's phone number
  • data — json, required, order data
  • done — boolean, required, defaults to false. If true, the order is completed

data field's JSON will be an array of objects with item id, name and price. Example:

[
    {
        "id": 1,
        "name": "Salad",
        "price": 4.99
    },
    {
        "id": 1,
        "name": "Salad",
        "price": 4.99
    }
]

We have 2 salads with the same id above. Since data is an array, an item can be added to it more than once.

POSTing data to Strapi

You can create a new entry in the Orders collection in two ways:

  • using Strapi's dashboard
  • using a POST request

Previously we were creating menu items using Strapi's dashboard.
It provides a nice UI to view, edit and delete data.

This time we need to create a new order using the API.

To achieve this, we can POST to /api/orders:

curl -X POST http://strapi-flutter-demo.orena.io/api/orders \
-H 'Authorization: bearer <api token>' \
-H 'Content-Type: application/json' \
-d '{
  "data": {
    "name": "Phoebe",
    "phone": "+0 338 869 0010",
    "data": [
      {
        "id": 1,
        "name": "Salad",
        "price": 4.99
      }
    ]
  }
}'

Note: you may have a forbidden error when creating new entries.
To fix it, go to "Settings" -> "API tokens" -> <your api token name> and make
sure that "Token type" is "Full access".
Read only tokens are good for read only scenarios (such as a website), they prevent bots from stealing the token and creating unwanted data.

Make sure "Token type" is set to "Full access"

Note: if you're getting "Missing data payload error" make sure Content-Type: application/json header is set and you're passing the data inside of the "data" field

Note: if you're getting "_ must be defined" error, make sure the \_ field is included in "data". This error is returned when a required field is not filled in.

After an item is created, you'll get it back:

{
  "data": {
    "id": 1,
    "attributes": {
      "name": "Phoebe",
      "phone": "+0 338810",
      "data": [{ "id": 1, "name": "Salad", "price": 4.99 }],
      "createdAt": "2022-10-12T09:22:05.729Z",
      "updatedAt": "2022-10-12T09:22:05.729Z",
      "publishedAt": "2022-10-12T09:22:05.719Z",
      "done": false
    }
  },
  "meta": {}
}

Notice that we didn't have to fill in a required done field, because Strapi knows
it defaults to false

Create a new order in the app

Knowing what to send to the server, let's send it. API interactions with Strapi are kept in MenuItemsRepository. Let's add a new method there to create an order:

class MenuItemsRepository {
  static Future<void> createOrder({
    required String name,
    required String phone,
    required List<MenuItemData> menuItems,
  }) async {
    var data = menuItems
        .map(
          (i) => {
            "id": i.id,
            "name": i.name,
            "price": i.price,
          },
        )
        .toList();

    var dio = Dio();

    await dio.post(
      '$kServerUrl/api/orders',
      data: {
        "data": {
          "name": name,
          "phone": phone,
          "data": data,
        }
      },
      options: Options(
        headers: {'Authorization': 'Bearer $kApiToken'},
        contentType: "application/json",
      ),
    );
  }
}

createOrder method accepts three named parameters:

  • String name — customer's name
  • String phone — customer's phone number
  • List<MenuItemData> menuItems — a list of menu items to create an order with

Then we need to create an API request. We'll map all MenuItemDatas into a Map of key-value pairs expected by the API.

Then we can make a POST request to /api/orders.

We need to specify Options.contentType as application/json,
otherwise dio won't know how to encode the data.
Then we can pass a map (similar to the one in the curl request before) as a data parameter.

Add order creation logic to the UI

Ask for name and a phone

As you can see, createOrder needs a name and a phone.
We can ask the user for those in an AlertDialog.

First, let's create a new stateful widget _OrderAlert in pages/order.dart:

class _OrderAlert extends StatefulWidget {
  const _OrderAlert();

  @override
  State<_OrderAlert> createState() => _OrderAlertState();
}

class _OrderAlertState extends State<_OrderAlert> {
  @override
  Widget build(BuildContext context) {
    return AlertDialog(
      title: const Text("Your name"),
      content: /** TODO: add name and phone fields */,
      actions: [
        ElevatedButton(
          onPressed: () {
            // TODO: return name and phone
          },
          child: const Text("Order"),
        )
      ],
    );
  }
}

It'll be a simple AlertDialog with a title of Text("Your name").

Actions are usually buttons that appear at the very bottom of the dialog. We can add an "Order" button there. We'll leave onPressed empty for now.

To allow the user to enter their name and phone we need to build a Form.

A typical Form looks like this:

final _formKey = GlobalKey<FormState>();

Widget build(BuildContext context) {
  return Form(
    key: _formKey,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        // TODO: TextFormFields
      ],
    ),
  );
}

_formKey variable is a global key of FormState. Form's child can be anything, but
here we'll make it a Column. Since Column is shown inside the alert, it should take up only the space it needs, so its mainAxisSize should be MainAxisSize.min.

Our Form needs two children:

final _nameController = TextEditingController();
final _phoneController = TextEditingController();

Widget build(BuildContext context) {
  return Form(
    key: _formKey,
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        TextFormField(
            controller: _nameController,
            validator: _notEmptyValidator,
            decoration: const InputDecoration(labelText: 'Name'),
        ),
        TextFormField(
            controller: _phoneController,
            validator: _notEmptyValidator,
            decoration: const InputDecoration(labelText: 'Phone'),
        )
      ],
    ),
  );
}

TextFormField is a text field. Its decoration.labelText can hold a string label.

To obtain the final input text we need to pass a TextEditingController. The text can be obtained with a get String text. You can get it like so: _nameController.text

Finally, we need to validate the fields. TextFormField has a validator property for that.
We'll pass a _notEmptyValidator function there.

String? _notEmptyValidator(String? value) {
  if (value?.isEmpty ?? true) {
    return "Cannot be empty";
  }
  return null;
}

When a validator returns a string, it will be shown as a field error text. When a validator returns null the field is considered valid.

_notEmptyValidator returns "Cannot be empty" string if a field's value is empty or null.

Get name and phone

Back in OrderPage widget I have created a _createOrder function. It will be called when the "Order" button is clicked.

_createOrder(BuildContext context) async {
  var items = _cartRepository.items;
  if (items.isEmpty) {
    return;
  }

  await showDialog(
    context: context,
    builder: (context) => const _OrderAlert(),
  );

  // TODO: get name and phone

  // TODO: make an API request

  // TODO: show the order was placed, clear cart
}

_createOrder first checks if the cart has items in it. If it doesn't, it does nothing.

Then we need to obtain name and phone, send an order to Strapi and show a popup saying the
order was created.

As we're obtaining name and phone from an _OrderAlert, we need to show it using showDialog. showDialog returns a future that completes after the user has finished
interacting with the dialog.

Then we need to handle what happens when an "Order" button is clicked in _OrderAlert:

// in _OrderAlert

@override
Widget build(BuildContext context) {
  return AlertDialog(
    // ...
    actions: [
      ElevatedButton(
        onPressed: () {
          var valid = _formKey.currentState!.validate();

          if (!valid) {
            return;
          }

          var name = _nameController.text;
          var phone = _phoneController.text;

          AutoRouter.of(context).pop(
            {"name": name, "phone": phone},
          );
        },
        child: const Text("Order"),
      )
    ],
  );
}

When an "Order" button is clicked, we ask the form if it is valid with _formKey.currentState!.validate().
A form with _formKey runs all validators on all fields inside it.
validate returns false if at least one of the validators returned an error text.
In this case the error text will be shown by the TextFormField, and we don't need to do anything.

If the form is valid, we know that both name and phone fields are not empty.
We can get a name string with _nameController.text and a phone with _phoneController.text.

Finally, we need to pass the data from _OrderAlert into OrderPage.

When an alert is shown, Flutter pushes it onto the navigation stack.
We can pop it from the navigation stack to close it.
Router's pop method accepts an optional parameter that will be returned after
showDialog is finished.

If we call pop in the alert passing a Map as an argument:

AutoRouter.of(context).pop(
    {"name": "Phoebe", "phone": "+0 82544091"},
);

We can access the map in a widget that pushed the alert:

var res = await showDialog(
  context: context,
  builder: (context) => const _OrderAlert(),
);

print("res: $res");

// prints:
//  res: {"name": "Phoebe", "phone": "+0 82544091"}

And that's how we can get name and phone in __createOrder.

Send the order to the server

We can finally send the order to the server.

_createOrder(BuildContext context) async {
  var items = _cartRepository.items;
  if (items.isEmpty) {
    return;
  }

  // _OrderAlert returns { "name", "phone" } map
  Map namePhoneMap = await showDialog(
    context: context,
    builder: (context) => const _OrderAlert(),
  );

  // get name and phone from the map
  String name = namePhoneMap["name"];
  String phone = namePhoneMap["phone"];

  // send the order to the server
  await MenuItemsRepository.createOrder(
    name: name,
    phone: phone,
    menuItems: items,
  );

  _cartRepository.clear();

  // show "Thank you for the order" dialog
  await showDialog(
    context: context,
    builder: (context) => const _ThankYouDialog(),
  );

  if (mounted) {
    // close OrderPage
    AutoRouter.of(context).pop();
  }
}

Then we can pass name, phone and a list of menu items (from the cart that OrderPage has) into MenuItemsRepository.createOrder.
MenuItemsRepository.createOrder will call an API and, if everything goes well, won't throw any errors. We need to wait for the API call to finish.

The order is created at this point. We can clear the cart and show a "Thank you for your order" dialog.

After all that, it would be nice to send the user back where they came from (probably back to the home screen).
Calling AutoRouter.of(context).pop() will remove the OrderPage from the navigation stack.

The app is ready 🎉

And the restaurant can see and modify all the orders without any extra coding:

Order info, such as name, phone and data can be viewed in Strapi's dashboard


Pro tip: save yourself from "How to self-host a Strapi" degree by making someone else host it for you.

We can host Strapi and other open source backends for you! No need to set up and maintain your own server. Sign up here, no credit card required

Blog
Writing cool articles about backends