Proper Routing in Flutter Web Using Beamer package: Shopping App— Part 5

Sanjib Maharjan
11 min readJun 27, 2023

We have successfully created a viable shopping app in a series of tutorials with a responsive design, authentication, error handling, API calls, etc. You can check out the previous parts of this tutorial in the links at the end of this tutorial. In this tutorial, we’ll be working on the routing that fits properly in our application.

If you have been following this tutorial, you are all set, and if not, you can clone the project at this link.

We have two pages so far in our app one is the dashboard and another one is the product detail page we have used the function Navigator.push() to navigate to the new page and Navigator.pop() to get back. We can keep using this and navigate to any page but since we are also developing for the web and this method does not rewrite the URL in the address bar it does not quite fit in this app to give the user the proper web browsing feel. And also we cannot go directly to certain pages without routing to the main page. In our case, we cannot directly navigate to the product details page without navigating to the main dashboard page.

Named Routes

We can use named routes to update the URL in the address bar corresponding to our page.

MaterialApp(
title: 'Shopping App Demo',
debugShowCheckedModeBanner: false,
routes: {
'/': (context) => Scaffold(
backgroundColor: Colors.white,
body: Dashboard(),
appBar: AppBar(
centerTitle: false,
title: Text("Shopping App"),
elevation: 0,
),
drawer: CustomDrawer(),
),
'/items': (context) => ItemDetail(null),
},
)

And use Navigator.pushNamed(context, '/items') function to navigate to the details page. ItemDetail class requires an argument to be passed on. But the problem with the above implementation is we have to predefine every route. We may have many items with different IDs and defining routes for every one of them is practically impossible. We may want to use the single route with a variable id entry as /items/:id.

Named routes using onGenerateRoute:

onGenerateRoute: (settings) {
// Handle '/'
if (settings.name == '/') {
return MaterialPageRoute(
builder: (context) => Scaffold(
backgroundColor: Colors.white,
body: Dashboard(),
appBar: AppBar(
centerTitle: false,
title: Text("Shopping App"),
elevation: 0,
),
drawer: CustomDrawer(),
),
settings: settings);
}

// Handle '/items/:id'
var uri = Uri.parse(settings.name);
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'items') {
var id = uri.pathSegments[1];
return MaterialPageRoute(
builder: (context) => ItemDetail(settings.arguments), settings: settings);
}

return MaterialPageRoute(builder: (context) => Text("404"));
},

First, let's move the Scaffold to the dashboard page then the above code becomes:

onGenerateRoute: (settings) {  if (settings.name == '/') {
return MaterialPageRoute(
builder: (context) => Dashboard(),
settings: settings);
}
var uri = Uri.parse(settings.name);
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'items') {
var id = uri.pathSegments[1];
return MaterialPageRoute(
builder: (context) => ItemDetail(settings.arguments), settings: settings);
}

return MaterialPageRoute(builder: (context) => Text("404"));
}

And use the following code to navigate to the details page passing the argument as:

Navigator.pushNamed(context, '/items/${item.id}', arguments: item);

The problem with this is when we navigate directly to the details page then the parameter becomes null. One way of fixing this would be passing the id to the detail page and using the API call to fetch the required information. To replicate this scenario, navigate the detail page and then refresh the page to get the following error.

Add a new parameter “id” to the ItemDetail class and we’ll be using this to fetch the item details if it's null.

class ItemDetail extends StatefulWidget {
final ProductItem item;
final int id;

const ItemDetail(this.item, {Key key, this.id}) : super(key: key);
...

And on main class, make the following changes on onGenerateRoute as:

var uri = Uri.parse(settings.name);
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'items') {
int id = int.tryParse(uri.pathSegments[1]) ;
return MaterialPageRoute(
builder: (context) => ItemDetail(settings.arguments, id: id,),
settings: settings);
}

We also have to fetch the item details from the id if not available and use a cubit to make the API call and fetch the information.

Create the “ui/common/item/detail/bloc/item_detail_state.dart” file with the following contents.

part of 'item_detail_cubit.dart';

@immutable
class ItemDetailState extends Equatable {
final bool loading;
final String error;

final ProductItem item;

bool get hasError => error != null && error.isNotEmpty;

bool get hasData => item != null;

const ItemDetailState({this.loading, this.error, this.item});

@override
List<Object> get props => [loading, error];

factory ItemDetailState.initial({loading}) {
return ItemDetailState(loading: loading ?? true, error: "");
}

ItemDetailState update({bool loading, ProductItem item, String error}) {
return ItemDetailState(
loading: loading ?? this.loading,
item: item ?? this.item,
error: error ?? "",
);
}

@override
String toString() => 'ItemDetailState { loading: $loading, error: $error }';
}

Also, create the “ui/common/item/detail/bloc/item_detail_cubit.dart” file with the following contents.

import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_shopping_app/ui/common/item/data/model/product_item.dart';
import 'package:flutter_shopping_app/ui/common/item/data/repository/product_item_repository.dart';
import 'package:injectable/injectable.dart';

part 'item_detail_state.dart';

@injectable
class ItemDetailCubit extends Cubit<ItemDetailState> {
final ProductItemRepository _productRepository;

ItemDetailCubit(ProductItemRepository repository)
: _productRepository = repository,
super(ItemDetailState.initial());

void loadItemDetail(int id, {ProductItem item}) async {
try {
if (item != null) {
emit(state.update(loading: false, item: item));
return;
}

emit(state.update(loading: true));

ProductItem _product = await _productRepository.getProductDetail(id);

emit(state.update(loading: false, item: _product));
} catch (e) {
final errorMsg = e.toString();
emit(state.update(loading: false, error: errorMsg));
}
}
}

Dont forget to run “fvm flutter packages pub run build_runner build — delete-conflicting-output

Add an implementation in the “ui/common/item/data/provider/product_item_provider.dart” file as:

Future<ProductItem> getProductInfo(int id) async {
//add some delay to give the feel of api call
await Future.delayed(Duration(seconds: 3));
final List _data = [
...MockUtil.getTopSellingItems()['data'],
...MockUtil.getFeaturedItems()['data'],
...MockUtil.getTrendingItems()['data']
];
final Map _item = _data.firstWhere(
(el) => el["id"] != null && int.tryParse("${el["id"]}") == id,
orElse: () => null);
if (_item == null) throw Exception("Item not found.");
return ProductItem.fromJson(_item);
}

Similarly, add the following contents to the “ui/common/item/data/repository/product_item_repository.dart” file as:

Future<ProductItem> getProductDetail(int id) async {
return await _provider.getProductInfo(id);
}

Add a loader file “ui/common/error_loader.dart” to show error, loading as well as the empty state.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_shopping_app/constant/color.dart';
import 'package:flutter_shopping_app/helper/responsive_helper.dart';

class ErrorLoaderCard extends StatelessWidget {
final EdgeInsets margin;
final EdgeInsets padding;
final double width;
final String errorMsg;
final VoidCallback retry;
final ResponsiveHelper responsiveHelper;
final Widget loadingContainer;

bool get _hasError => errorMsg != null && errorMsg.isNotEmpty;

const ErrorLoaderCard(
{Key key,
this.margin,
this.padding,
this.width,
this.loadingContainer,
this.errorMsg,
this.retry,
this.responsiveHelper})
: super(key: key);

@override
Widget build(BuildContext context) {
final ResponsiveHelper _responsiveHelper =
responsiveHelper ?? ResponsiveHelper(context: context);
return Container(
width: width,
padding: padding ?? const EdgeInsets.all(10),
margin: margin ?? EdgeInsets.all(_responsiveHelper.defaultGap),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
offset: Offset(0, 1), // changes position of shadow
),
],
borderRadius: BorderRadius.circular(10),
color: Colors.white,
border: Border.all(width: 1, color: ThemeTextColorLightest)),
child: _hasError
? _errorContainer()
: (loadingContainer ?? _loaderContainer(_responsiveHelper)),
);
}

_loaderContainer(responsiveHelper) {
return Container(
color: ThemeTextColorLightest,
);
}

_errorContainer() {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text(
errorMsg,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.red),
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.white),
backgroundColor: MaterialStateProperty.all<Color>(Colors.red)),
onPressed: retry,
child: Text('Retry'),
)
],
);
}
}

class EmptyCard extends StatelessWidget {
final String message;
final EdgeInsets margin;
final EdgeInsets padding;
final ResponsiveHelper responsiveHelper;

const EmptyCard(
{Key key, this.message, this.padding, this.responsiveHelper, this.margin})
: super(key: key);

@override
Widget build(BuildContext context) {
final ResponsiveHelper _responsiveHelper =
responsiveHelper ?? ResponsiveHelper(context: context);
return Container(
padding: padding ?? const EdgeInsets.all(10),
alignment: Alignment.center,
margin: margin ?? EdgeInsets.all(_responsiveHelper.defaultGap),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: ThemeTextColorLighter,
width: 1,
)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(Icons.clear),
SizedBox(
width: 10,
),
Expanded(
child: Text(
message ?? "No data",
))
],
));
}
}

Also, make changes in the “ui/common/item/detail/item_detail.dart” file as:

import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_rating_bar/flutter_rating_bar.dart';
import 'package:flutter_shopping_app/constant/color.dart';
import 'package:flutter_shopping_app/ui/common/base/app_wrapper.dart';
import 'package:flutter_shopping_app/ui/common/error_loader.dart';
import 'package:flutter_shopping_app/ui/common/form/custom_button.dart';
import 'package:flutter_shopping_app/ui/common/incrementer.dart';
import 'package:flutter_shopping_app/ui/common/item/data/model/product_item.dart';
import 'package:flutter_shopping_app/ui/common/item/detail/bloc/item_detail_cubit.dart';
import 'package:flutter_shopping_app/util/dialog_util.dart';
import 'package:flutter_shopping_app/util/message_util.dart';
import 'package:flutter_shopping_app/util/pref_util.dart';
import 'package:get_it/get_it.dart';

class ItemDetailPage extends StatelessWidget {
final ProductItem item;
final int id;

const ItemDetailPage(this.item, {Key key, this.id}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: SafeArea(
child: Scaffold(
body: BlocProvider(
create: (context) =>
GetIt.I.get<ItemDetailCubit>()..loadItemDetail(id),
child: BlocBuilder<ItemDetailCubit, ItemDetailState>(
builder: (context, ItemDetailState state) {
return ItemDetail(state, id);
}),
),
),
),
);
}
}

class ItemDetail extends StatefulWidget {
final ItemDetailState state;
final int id;

const ItemDetail(this.state, this.id, {Key key}) : super(key: key);

@override
_ItemDetailState createState() => _ItemDetailState();
}

class _ItemDetailState extends State<ItemDetail> {
int _noOfItems = 1;

ProductItem get _item => widget.state.item;

_retry() {
context.read<ItemDetailCubit>().loadItemDetail(widget.id);
}

@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
child: AppWrapper(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
style: ButtonStyle(
foregroundColor:
MaterialStateProperty.all(ThemeColor)),
onPressed: () => _goBack(context),
icon: Icon(Icons.keyboard_arrow_left),
label: Text("Back")),
),
if (widget.state.hasError || widget.state.loading)
SizedBox(
height: 200,
child: ErrorLoaderCard(
errorMsg: widget.state.error,
width: double.infinity,
retry: _retry,
),
)
else ...[
SizedBox(
height: 30,
),
Center(
child: Image.network(
_item.imageUrl,
height: 300,
),
),
SizedBox(
height: 30,
),
Text(
_item.name,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w500),
),
SizedBox(
height: 10,
),
RatingBar.builder(
initialRating: min(_item.rating ?? 0, 5),
minRating: 1,
itemSize: 20,
direction: Axis.horizontal,
allowHalfRating: true,
itemCount: 5,
itemBuilder: (context, _) =>
Icon(Icons.star, color: Colors.amber),
),
SizedBox(
height: 30,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Incrementer(
setIncrementer: _updateCartCount,
value: _noOfItems,
),
Text(
_item.price,
style: TextStyle(fontSize: 30),
)
],
),
SizedBox(
height: 30,
),
Text("About the Product",
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
SizedBox(
height: 15,
),
Text(
_item.description ?? "",
style: TextStyle(
fontSize: 14,
color: ThemeTextColor,
),
textAlign: TextAlign.justify,
),
if (_item.hasBenefits) _addItemBenefits(),
],
]),
),
),
),
),
if (widget.state.hasData) _addToCartWidget()
],
);
}

Widget _addToCartWidget() {
return Container(
// alignment: Alignment.bottomCenter,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(width: 1.0, color: ThemeTextColorLighter))),
padding: const EdgeInsets.all(8.0),
child: AppWrapper(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.favorite_border),
onPressed: _addToFavourite,
),
CustomButton(
label: "Add to cart",
padding: EdgeInsets.symmetric(
vertical: 15,
horizontal: 60,
),
onPressed: _addToCart,
),
],
),
),
);
}

_addToCart() {
if (PrefUtil.isUserLoggedIn()) {
// make api call to add it to cart
MessageUtil.showSuccessMessage("Added to the cart.");
} else {
DialogUtil.openLoginPopup(context);
}
}

_addToFavourite() {
if (PrefUtil.isUserLoggedIn()) {
//make api call to add favourite
MessageUtil.showSuccessMessage("Added to your favourites.");
} else {
DialogUtil.openLoginPopup(context);
}
}

Widget _addItemBenefits() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 20,
),
Text("Benefits",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
SizedBox(
height: 10,
),
...List.generate(
_item.benefits.length,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.arrow_forward,
size: 16,
),
SizedBox(
width: 10,
),
Expanded(
child: Text(
_item.benefits[index],
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: ThemeTextColor),
))
],
),
),
),
],
);
}

_goBack(BuildContext context) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
Navigator.pushNamed(context, '/');
}
}

_updateCartCount(int count) {
setState(() {
_noOfItems = count;
});
}
}

And make the following changes to the main.dart file as:

var uri = Uri.parse(settings.name);
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'items') {
int id = int.tryParse(uri.pathSegments[1]);
return MaterialPageRoute(
builder: (context) => ItemDetailPage(
settings.arguments,
id: id,
),
settings: settings);
}

The routing we’ve implemented so far does the job for most web and mobile cases. Navigating to “http://localhost:port/#/items/1” both by clicking the item card from the dashboard and by entering the URL in the address bar works. But on the web going back does not always work, it sometimes does not update the URL in the address bar properly.

Navigator 2.0

This uses the declarative approach unlike the imperative approach used by the former navigator 1.0. It is comparatively complex with some new concepts and hence we’ll be using a library beamer to implement a router in our application instead. Add beamer: ^0.13.3 as a dependency in the pubspec.yaml file.

Create the location class for the dashboard page.

class DashboardLocation extends BeamLocation {
DashboardLocation({BeamState state}) : super(state);

@override
List<String> get pathBlueprints => ['/'];

@override
List<BeamPage> buildPages(BuildContext context, BeamState state) {
return [
BeamPage(
key: ValueKey('home'),
title: 'Home',
child: Dashboard(),
)
];
}
}

Similarly, we’ll also be needing a location class for the item detail page.

class ItemDetailLocation extends BeamLocation {
ItemDetailLocation({BeamState state}) : super(state);

@override
List<String> get pathBlueprints => ['/items/:itemId'];

@override
List<BeamPage> buildPages(BuildContext context, BeamState state) {
final data = state.data['item'];
return [
BeamPage(
key: ValueKey('item-${int.tryParse(state.pathParameters['itemId'])}'),
title: 'Items',
child: ItemDetailPage(data,
id: int.tryParse(state.pathParameters['itemId'])),
)
];
}
}

Important: here the key should be different for each instance of item details page with different items. Assuming id to be unique for each item, item-${int.tryParse(state.pathParameters[‘itemId’])} would be unique for all of the pages.

Now replace the MaterialApp with MaterialApp.router as:

MaterialApp.router(
title: 'Shopping App Demo',
debugShowCheckedModeBanner: false,
routeInformationParser: BeamerParser(),
backButtonDispatcher:
BeamerBackButtonDispatcher(delegate: routerDelegate),
builder: BotToastInit(),
routerDelegate: routerDelegate,
theme: ThemeData(
primarySwatch: ThemeWhite,
),
)

with router delegate as:

final routerDelegate = BeamerDelegate(
navigatorObservers: [BotToastNavigatorObserver()],
locationBuilder: BeamerLocationBuilder(
beamLocations: [
DashboardLocation(),
ItemDetailLocation(),
],
));

Now just execute context.beamToNamed(‘/items/${item.id}’, data: {‘item’: item}); to navigate to the detail page. Similarly context.beamBack(); to go to the previous page. We can also use the functionBeamer.setPathUrlStrategy(); for removing # from the web address bar URL.

There is also an option to guard the routes to restrict the users from visiting some pages if they don't match a given condition. For example: for visiting the cart page, the user must be logged in to the system.

Let's create a new page to demonstrate the use of guards.

import 'package:beamer/beamer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_shopping_app/ui/common/base/app_wrapper.dart';
import 'package:flutter_shopping_app/ui/common/custom_drawer.dart';
import 'package:flutter_shopping_app/util/pref_util.dart';

class UserCartLocation extends BeamLocation {
UserCartLocation({BeamState state}) : super(state);

@override
List<String> get pathBlueprints => ['/cart'];

@override
List<BeamPage> buildPages(BuildContext context, BeamState state) {
return [
BeamPage(
key: ValueKey('cart'),
title: 'User Cart',
child: UserCart(),
)
];
}

@override
List<BeamGuard> get guards => [
// allow user to navigate to cart page only if they are logged in
BeamGuard(
pathBlueprints: ['/cart'],
check: (context, location) => PrefUtil.isUserLoggedIn(),
beamToNamed: "/"),
];
}

class UserCart extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white60,
child: SafeArea(
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
centerTitle: false,
title: Text("User Cart"),
elevation: 0,
),
drawer: CustomDrawer(),
body: SingleChildScrollView(
child: AppWrapper(
child: Column(
children: [Text("This is the cart page.")],
),
),
),
),
),
);
}
}

Users can navigate to this cart page only if they are logged in. If they try to navigate to this page forcefully for eg. by entering the URL directly in the address bar then we can show a forbidden page but in our scenario, we’ll be redirecting the user to the home page. Similarly, if the user tries to navigate to this page by clicking a button then we‘ll be presenting the login popup.

First, add this new location to the router delegate. And add the cart button in the dashboards app bar as:

.....
_goToCart(BuildContext context) {
// ask user to login if they are not already
if (PrefUtil.isUserLoggedIn()) {
context.beamToNamed('/cart');
} else {
DialogUtil.openLoginPopup(context);
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
centerTitle: false,
title: Text("Shopping App"),
elevation: 0,
actions: [
IconButton(
icon: Icon(Icons.shopping_cart),
onPressed: () => _goToCart(context))
],
),
......

This does the job of routing in our current application and we can change the configuration as needed or add new routes if necessary.

Project Starter Link: https://github.com/cshanjib/flutter_shopping_app/tree/section5-start
Project Completed Link: https://github.com/cshanjib/flutter_shopping_app/tree/section5-end

Prev — Part 4: Responsive Design
Next —Part 6: Pagination in Flutter(Lazy Loading, Paginated Truncated Display, etc.)

Check out other tutorials in this series:

Part 1: Designing the E-commerce App
Part 2: API calls and Dependency Injection using the get_it package
Part 3: Authentication and Login Design using the flutter_bloc package
Part 4: Responsive Design
Part 5: Proper Routing in Flutter Web using beamer package
Part 6: Pagination in Flutter(Lazy Loading, Paginated Truncated Display, etc.)

If you like this tutorial please hit 👏 as many times as you like and also leave comments if you have any questions or suggestions. Thank you!

#flutter, #flutter_app_development, #ecommerce_application, #beamer #routing #routing_in_flutter_web

--

--