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:
- on
/details-page
to add an item to the cart - 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
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
. Iftrue
, 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.
POST
ing 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.
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 nameString phone
— customer's phone numberList<MenuItemData> menuItems
— a list of menu items to create an order with
Then we need to create an API request. We'll map all MenuItemData
s 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:
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