Logo
Back

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 ItemWidgets.
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

Blog
Writing cool articles about backends