Make a food delivery app with DollarBackend + Flutter (2/3)
We're continuing making a food delivery app for iOS/Android using Strapi 4 as a ready-made backend and Dart + Flutter for writing the app. In this article, I'll tell you how to show Menu Item's details.
In the last article, we have started creating a food delivery app. We have learned how to add custom entries to Strapi and how to show data from Strapi in a mobile app.
Today we will be adding product details page. First of all, we need to create a page in the app that will be showing product details.
As always, all the source code is available on GitHub;
Creating a new page in Flutter
In Flutter everything is a widget, pages included.
Flutter has a navigation stack — a stack of widgets that represent different pages.
When navigating between pages, we can just push new widgets onto the navigation stack like so:
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const OrderPage()),
);
But this can get messy pretty quickly, especially if you have a lot of routes
and you need to rename one in the future.
To keep things cleaner we can use a router.
We'll use auto_route for route management.
Add the dependencies to pubspec.yaml
:
dependencies:
# ... other dependencies here
auto_route: ^5.0.1
dev_dependencies:
auto_route_generator: ^5.0.1
build_runner:
We need to have 3 pages for our app:
- home page — shows all menu items (we already have it in
pages/home.dart
!) - details page — shows a single menu item in detail
- order page — where the user can see all items added to a cart and place an order
I have created two new files in the pages
folder: details.dart
and order.dart
.
In each of those files I have created two new empty stateless widgets: DetailsPage
and OrderPage
.
(see code example here)
Now all we need is to set up a new router. For this, create a new placeholder class in router.dart
in the lib
folder:
import 'package:auto_route/auto_route.dart';
import 'pages/home.dart';
import 'pages/details.dart';
import 'pages/order.dart';
@MaterialAutoRouter(
replaceInRouteName: 'Page,Route',
routes: <AutoRoute>[
AutoRoute(page: HomePage, initial: true),
AutoRoute(page: DetailsPage),
AutoRoute(page: OrderPage),
],
)
class $AppRouter {}
And generate router.gr.dart
by running in the terminal:
flutter packages pub run build_runner build
Lastly, import the router into main.dart
and tell Flutter to use it:
import 'router.gr.dart';
// ...
class MyApp extends StatelessWidget {
// ...
final _appRouter = AppRouter();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
// ...
routerDelegate: _appRouter.delegate(),
routeInformationParser: _appRouter.defaultRouteParser(),
);
}
}
The router should be setup now.
Navigating from HomePage to DetailsPage
There are a few ways to navigate between pages.
But let me show you the code first:
import 'package:flutter/material.dart';
import 'package:auto_route/auto_route.dart';
// this imports routes, such as HomeRoute
import '../router.gr.dart';
class DetailsPage extends StatelessWidget {
const DetailsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: ElevatedButton(
onPressed: () {
var router = AutoRouter.of(context);
// Navigate using routes
router.push(HomeRoute());
// or by using paths.
// `details-page` is this page, it will just push this page onto the stack again
router.pushNamed('/details-page');
},
child: Text("navigate"),
),
);
}
}
To navigate we'll need an AutoRouter
instance. It can be obtained with AutoRouter.of(context)
.
Then we can push a route HomeRoute
. It should be imported from router.gr.dart
.
How is that different from just doing Navigator.push(HomePage)
? (a reminder: Navigator
is
Flutter's built in way of adding/deleting widgets from stack)
HomeRoute
is generated by auto_route
. If we were to replace HomePage
with, say HomeMenuPage
all we would need to do is to update router.gr.dart
and voila! auto_route
will know that
HomeRoute
now refers to HomeMenuPage
widget instead of HomePage
.
We can also pushNamed
routes. Named routes are a way to access routes using their names.
When we specified AutoRoute(page: DetailsPage),
in router.dart
, auto_route
generated
a DetailsRoute
with name = /details-page
.
As you can see, using a router makes it more convenient to navigate between pages in Flutter.
To trigger navigation from HomePage
to DetailsPage
we need to wait for the onTap event in our ItemWidget
:
// ItemWidget renders MenuItemData into a square tile for a HomePage grid
class ItemWidget extends StatelessWidget {
final MenuItemData menuItem;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
AutoRouter.of(context).push(
DetailsRoute(id: menuItem.id),
);
},
// render the rest here
child: Container()
);
};
)
As you remember, our home page shows a grid of ItemWidget
s.
Each ItemWidget
renders menu item's name, photo and price into a square tile.
We need to wrap that square tile with a GestureDetector. We're interested in the onTap
event, which is called every time user
tapped a widget.
When our widget is tapped, AutoRouter.of(context).push()
will push its argument
onto the navigation stack.
The router (from router.dart
), knows to translate DetailsRoute
into DetailsPage
.
// in pages/details.dart
class DetailsPage extends StatefulWidget {
final int id;
const DetailsPage({required this.id, this.initialPhotoUrl, super.key});
}
Notice that a parameter is passed to DetailsPage
: DetailsRoute(id: menuItem.id)
.
auto_route
automatically generates route parameters from widget parameters: if DetailsPage
's constructor takes a required parameter int id
, auto_route
knows that
DetailsRoute
needs to have a required parameter int id
.
Adding item details to Strapi
The next thing to do is to create more fields for us to show on the details page.
I have created two new fields:
description
— a required long text field which will be keeping item's description.
details
— a required json field which will be keeping an array of objects.
Each of the objects should have two properties: key
— a string describing the property
and value
— a string with a property.
Here's an example of what details
field should look like when it's filled:
[
{
"key": "Calories",
"value": "599 cal"
},
{
"key": "Is vegan",
"value": "No"
}
]
I'll fill in the fields for the menu items I created earlier and we'll see how to get those in Flutter.
Accessing single entry from Strapi's API
Previously we were looking for all menu items. We made a GET
request to /api/menu-items
.
To access only one menu item, we need to make a similar request, but specifying item id this time:
curl \
-H 'Authorization: bearer <auth token>' \
http://strapi-flutter-demo.orena.io/api/menu-items/1
It will return us a similar API response:
{
"data": {
"id": 1,
"attributes": {
"name": "Salad",
"price": 3.45,
"createdAt": "2022-10-06T09:23:50.748Z",
"updatedAt": "2022-10-07T11:31:43.120Z",
"publishedAt": "2022-10-07T11:26:49.041Z",
"description": "A salad with cheese, tomatoes and radish.",
"details": [
{ "key": "Is vegan", "value": "No" },
{ "key": "Ingredients", "value": "Salad, tomato, cheese, radish" }
]
}
},
"meta": {}
}
Notice that data
field is no longer an array: it is an object. Also, our data field is not a string, but an object we specified in the dashboard.
(bonus tip) Optimizing Strapi's responses
Strapi will return all fields for an entry by default. To choose only the fields that we need,
we can use the fields parameter:
var response = await dio.get(
'$kServerUrl/api/menu-items',
options: Options(
headers: {'Authorization': 'Bearer $kApiToken'},
),
queryParameters: {
"populate": "photo",
"fields": ["name", "price"]
},
);
fields
parameter can be a string or an array of strings.
To select only 'name' and 'price' fields, set fields
to ["name", "price"]
.
photo
field is populated via populate
and Strapi knows to select it as well.
Rendering details in a new Flutter widget
So far MenuItemData
in domain
looks like this:
class MenuItemData {
int id;
String name;
PhotoUrl? photo;
double price;
}
Since we're showing details, we need to add details data somewhere.
We could create a new class with only the details, but let's just add the fields
to MenuItemData
instead:
class MenuItemData {
int id;
String name;
PhotoUrl? photo;
double price;
String? description;
List? details;
}
I have added 2 fields: an optional string description
and an optional List
details.
Having those fields in place, we could implement the UI.
I have created a new menu_item_details.dart
file.
There I will create a widget that can render MenuItemData
with full details
class ItemDetails extends StatelessWidget {
final MenuItemData item;
const ItemDetails({required this.item, super.key});
@override
Widget build(BuildContext context) {
// build ListView with name, description and details here
}
}
We will render description using a Text widget.
To render details, we can use Dart's map method to convert a list with arbitrary data into a list of widgets:
// inside ItemDetails widget
List<Widget> _buildItemDetails() {
if (item.details == null) return [];
return item.details!.map((d) =>
Row(
children: [
Text(d["key"])
// spacer is used to place two texts on opposite sides
const Spacer(),
Text(d["value"],
],
),
).toList()
}
To build item details we will iterate over all items and build a Row widget for each key-value pair.
For convenience, I have put this logic into _buildItemDetails
method inside of
our class.
Finally, we can put it all together into a ListView:
// inside ItemDetails widget
Widget build(BuildContext context) {
// largestImg returns a URL for the largest image
String photoUrl = PhotoUrl.largestImg(item.photo);
return ListView(
children: [
if (photoUrl != null) Image.network(photoUrl),
Text(item.name),
if (item.description != null) Text(item.description!),
..._buildItemDetails()
],
);
}
The code above is pretty self-explanatory: if there's a photo, add an Image
,
then add a text with item.name
, then add a text with item.description
and
finally add whatever _buildItemDetails
from earlier returns.
This is how ItemDetails
widget works.
Note: the code sample above is shortened for readability. See full code in this tutorial's GitHub repo.
Adding ItemDetails
to details page
Let's add it to our DetailsPage
:
class DetailsPage extends StatefulWidget {
final int id;
// ...
}
class _DetailsPageState extends State<DetailsPage> {
MenuItemData? _item;
@override
void initState() {
super.initState();
_fetchData();
}
_fetchData() async {
// TODO: fetch data here
}
@override
Widget build(BuildContext context) {
var item = _item;
if (item == null) {
// return "Not found" scaffold
}
return Scaffold(
appBar: AppBar(
title: Text("${item.name} details"),
),
body: ItemDetails(item: item),
);
}
}
That looks like a lot of code, but it is not. We have created a StatefulWidget DetailsPage
and passed in a parameter
int id
— id of an item we need to display the details for.
Then, in initState
we're calling _fetchData
to fetch details from strapi for an entry with an id
and save it into _item
variable. _fetchData
is empty for now.
If _item
is null, build
returns "Not found" scaffold.
Otherwise, it returns ItemDetails
widget for our item.
Fetching data for a specific item from Strapi
Knowing what we will be showing, all is left is to get the data from Strapi into our app.
Let's add a new method to MenuItemsRepository
. We'll call it getMenuItem
and it will take
one parameter id
:
class MenuItemsRepository {
static Future<MenuItemData?> getMenuItem(int id) async {
var dio = Dio();
var response = await dio.get(
'$kServerUrl/api/menu-items/$id',
options: Options(
headers: {'Authorization': 'Bearer $kApiToken'},
),
queryParameters: {"populate": "photo"},
);
var data = response.data["data"];
var items = MenuItemData.fromStrapiList([data]);
if (items.isNotEmpty) {
return items.first;
}
return null;
}
}
First, we initialize dio. Then we make a request. This time we need to GET
/api/menu-items/$id
.
Strapi returns us an object and we need to deserialize it.
We already have most of the logic we need in MenuItemData.fromStrapiList
, so let's reuse it.
fromStrapiList
takes a dynamic
, but expects it to be a list, so to reuse it we need to create
a new list with one item in it.
After fromStrapiList
has deserialized our item, we will take it. If a list is empty,
we will simply return null
.
getMenuItem()
looks very similar to getMenuItems()
we created in part one, but, as you can see, there are a few differences.
As you remember, we did not change MenuItemData.fromStrapiList
yet, so let's do it now:
static List<MenuItemData> fromStrapiList(dynamic data) {
List<MenuItemData> items = List.empty(growable: true);
for (var d in data) {
var attrs = d["attributes"];
var item = MenuItemData(
// ... other fields
description: attrs["description"],
details: attrs["details"],
);
// other deserialization logic
}
return items;
}
I have simplified the code we had before to make it more readable. But, as always, feel free
to browse full code on GitHub.
We have changed only 2 things: description
is from attrs["description"]
and details
is from attrs["details"]
. Remember: dio deserializes JSON
response into dynamic
, and we know that details
field Strapi returns contains a list.
All done here!
Putting it all together
Finally, _DetailsPageState._fetchItems
looks like this:
_fetchItems() async {
var item = await MenuItemsRepository.getMenuItem(widget.id);
_item = item;
if (mounted) { setState(() {}); }
}
We can use widget
variable to access widget's parameters from Flutter's state.
Then we just need to update our internal variable and re-render. We're done!
To recap, we:
- added a router (using
auto_route
package) - added more fields to Menu Item collection type in Strapi
- retrieved a single Menu Item by id from Strapi
- showed
MenuItemData
on a details page
If you think that was a lot, imagine having to set up a Strapi server on top of that. Luckily,
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