Flutter Bloc shopping cart app — Part 1
In the world of Flutter, there are numerous tutorials on the basics of BLOC (Business Logic Component) architecture. However, most of them focus on single BLOC examples or simple use cases. As developers, we often encounter more complex scenarios in real-world applications where multiple BLOCs need to work harmoniously together.
This tutorial series aims to bridge that gap and provide a more in-depth understanding of BLOC architecture with realistic, complex examples.
This series of tutorials will be for that , We will be reusing examples written by Felix Angelov in the main bloc repository.
This series will be shared on multiple parts
Part 1 : The shopping cart app
If you want to access the end code of this tutorial , you can check it on github
Modifying the shopping cart app
- Adding Quantity: Enhance the shopping items by allowing users to select a quantity for each item they wish to purchase.
- Modifying BLOC Logic: Improve the BLOC logic to handle the updated quantity of each item in the shopping cart.
- UI Enhancements: Modify the user interface to include buttons for adding and removing items, as well as displaying the selected items with their respective quantities.
Part 2:
We will be modifying the second screen of this exmaple which is the shopping cart to support the quantity and the correct total pricing
part3
We will add the login screen from another example , profile screen and log out button
Part4 :
We will add Presistency features , User will log out and log back in to still find his items in the cart
So please Follow this page so you can be notified of the next parts.
Now let’s get to work on Part 1
https://github.com/felangel/bloc/tree/master/examples
First I will download the bloc repo alltogether to my computer Then I will copy the flutter_shopping_cart to a new folder .
This will be our working space
Go to the pubspec.yaml and under the dev_dependences delete the bloc , flutter_bloc and bloc_test as we will already get them from the pub.dev instead of the local paths
First let’s run the shopping cart example :
Let’s do some improvements on this example
You can notice we can only add one item per product , what if we need to add multiple items of the same product.
So let’s do that :
Go to models/cart.dart
This will be our starting point
You will notice that the cart has only a list of ‘items’
final List<Item> items;
let’s add a map that includes each shopping item and its quantity
class Cart extends Equatable {
const Cart({this.items = const <Item>[] , this.itemsWithQuantity = const {} });
final List<Item> items;
final Map<Item, int> itemsWithQuantity;
int get totalPrice {
return items.fold(0, (total, current) => total + current.price);
}
@override
List<Object> get props => [items];
}
In the shopping_repository.dart ,Lets add our
shopping lists map(items with quantity)
addItemToCartwQuantity : which should increment the quantity by 1 if the product is already in the cart or just put 1 if the product didn’t exist
removeItemFromCartwQuantity : which should decrement the quantity by 1 if the product is already in the cart or just enforce a 0 if the product doesn’t exist
class ShoppingRepository {
final _items = <Item>[];
final itemsWithQuantity = <Item,int>{};
Future<List<String>> loadCatalog() => Future.delayed(_delay, () => _catalog);
Future<List<Item>> loadCartItems() => Future.delayed(_delay, () => _items);
Future<Map<Item,int>> loadCartItemsWithQuantity() => Future.delayed(_delay, () => itemsWithQuantity);
void addItemToCart(Item item) => _items.add(item);
void addItemToCartwQuantity(Item item) => itemsWithQuantity.update(item, (oldvalue) => oldvalue+1 , ifAbsent: ()=>1) ;
void removeItemFromCart(Item item) => _items.remove(item);
void removeItemFromCartwQuantity(Item item) => itemsWithQuantity.update(item, (oldvalue) => oldvalue-1 , ifAbsent: ()=>0) ;
}
Next let’s modify our cart_bloc.dart
We need to modify three functions here
_OnStarted : we added our map and called our repository function that we have just created
As well as passing in the itemsWithQuantity map to the cart inside when we emit the state
Future<void> _onStarted(CartStarted event, Emitter<CartState> emit) async {
emit(CartLoading());
try {
final items = await shoppingRepository.loadCartItems();
final itemsWithQuantity = await shoppingRepository.loadCartItemsWithQuantity();
emit(CartLoaded(cart: Cart(items: [...items] , itemsWithQuantity: itemsWithQuantity )));
} catch (_) {
emit(CartError());
}
}
similarly with _onItemAdded
Future<void> _onItemAdded(
CartItemAdded event,
Emitter<CartState> emit,
) async {
final state = this.state;
if (state is CartLoaded) {
try {
shoppingRepository.addItemToCart(event.item);
shoppingRepository.addItemToCartwQuantity(event.item);
// state.cart.itemswithquantity.update(event.item, (value) => value+1 , ifAbsent: () => 1);
emit(CartLoaded(cart: Cart(items: [...state.cart.items, event.item],
itemsWithQuantity: state.cart.itemsWithQuantity
)));
} catch (_) {
emit(CartError());
}
}
}
and _onItemRemoved :
void _onItemRemoved(CartItemRemoved event, Emitter<CartState> emit) {
final state = this.state;
if (state is CartLoaded) {
try {
shoppingRepository.removeItemFromCart(event.item);
shoppingRepository.removeItemFromCartwQuantity(event.item);
emit(
CartLoaded(
cart: Cart(
items: [...state.cart.items]..remove(event.item),
itemsWithQuantity: state.cart.itemsWithQuantity
),
),
);
} catch (_) {
emit(CartError());
}
}
}
Now we have finished our logic work. Its time to modify our UI
We will aim to have something like that
So in catalog_page.dart lets go all the way down to ‘CatalogListItem’
And add two new widgets:
quantityDisplayed(item: item),
RemoveButton(item: item),
class CatalogListItem extends StatelessWidget {
const CatalogListItem(this.item, {super.key});
final Item item;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme.titleLarge;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: LimitedBox(
maxHeight: 48,
child: Row(
children: [
AspectRatio(aspectRatio: 1, child: ColoredBox(color: item.color)),
const SizedBox(width: 24),
Expanded(child: Text(item.name, style: textTheme)),
const SizedBox(width: 24),
AddButton(item: item),
quantityDisplayed(item: item),
RemoveButton(item: item),
],
),
),
);
}
}
First lets start our surgery by cleaning up the AddButton widget
We will delete everything here and leave only an Icon of a ‘+’
return TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed: isInCart
? null
: () => context.read<CartBloc>().add(CartItemAdded(item)),
child: isInCart
? const Icon(Icons.check, semanticLabel: 'ADDED')
: const Text('ADD'),
);
So Here’s how it will look like :
class AddButton extends StatelessWidget {
const AddButton({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return switch (state) {
CartLoading() => const CircularProgressIndicator(),
CartError() => const Text('Something went wrong!'),
CartLoaded() => Builder(
builder: (context) {
final isInCart = state.cart.items.contains(item);
return TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed:
() => context.read<CartBloc>().add(CartItemAdded(item)),
child:
const Icon(Icons.add, semanticLabel: 'Add')
);
},
)
};
},
);
}
}
Lets resue the AddButton with our nre RemoveButton :
class RemoveButton extends StatelessWidget {
const RemoveButton({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return switch (state) {
CartLoading() => const CircularProgressIndicator(),
CartError() => const Text('Something went wrong!'),
CartLoaded() => Builder(
builder: (context) {
final isInCart = state.cart.items.contains(item);
return TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed:
() => context.read<CartBloc>().add(CartItemRemoved(item)),
child:
// ? const Icon(Icons.check, semanticLabel: 'Remove')
// ? Text('+ ${state.cart.itemsWithQuantity[item]}')
const Icon(Icons.remove, semanticLabel: 'Remove'),
);
},
)
};
},
);
}
}
Then our quantityDisplayed widget : which should be displayed in the middle between the + and (-) buttons
class quantityDisplayed extends StatelessWidget {
const quantityDisplayed({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return switch (state) {
CartLoading() => const CircularProgressIndicator(),
CartError() => const Text('Something went wrong!'),
CartLoaded() => Builder(
builder: (context) {
return
Text('${state.cart.itemsWithQuantity[item]}');
},
)
};
},
);
}
}
Now let’s restart our app and see :
Looks like the job is done , However 0 should be displayed instead of null ,
The easiest way is to use the null colascening operator ?? and provide 0 as default value
class quantityDisplayed extends StatelessWidget {
const quantityDisplayed({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return switch (state) {
CartLoading() => const CircularProgressIndicator(),
CartError() => const Text('Something went wrong!'),
CartLoaded() => Builder(
builder: (context) {
return
Text('${state.cart.itemsWithQuantity[item]??0}');
},
)
};
},
);
}
}
Now we can see default zero has been displayed
However there is a new problem that I noticed :
When the app restarts there are redundant loaders for each of the add and remove buttons , so let’s clean and optimise this
All we need is to remove the switch state from the Add and Remove buttons
class RemoveButton extends StatelessWidget {
const RemoveButton({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return Builder(
builder: (context) {
return TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed:
() => context.read<CartBloc>().add(CartItemRemoved(item)),
child:
// ? const Icon(Icons.check, semanticLabel: 'Remove')
// ? Text('+ ${state.cart.itemsWithQuantity[item]}')
const Icon(Icons.remove, semanticLabel: 'Remove'),
);
},
);
},
);
}
}
class AddButton extends StatelessWidget {
const AddButton({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocBuilder<CartBloc, CartState>(
builder: (context, state) {
return
TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed:
// isInCart
// ? () => context.read<CartBloc>().add(CartItemRemoved(item))
// :
() => context.read<CartBloc>().add(CartItemAdded(item)),
child:
// isInCart
// ?
const Icon(Icons.add, semanticLabel: 'Add')
// ? Text('+ ${state.cart.itemsWithQuantity[item]}')
// : const Text('ADD'),
);
},
);
}
}
Now much better :
Now let’s ask ourselves is our code really optimised in the best way or is there better?
The answer lies with our usage of the BlocBuilder in the three widgets
Do we need to build the Add and Remove buttons everytime the state changes ? No we don’t
so we should use the BlocListener instead
let’s do that , this will be the last thing in this tutorial
In the RemoveButton and the AddButton ,do the following
class RemoveButton extends StatelessWidget {
const RemoveButton({required this.item, super.key});
final Item item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocListener<CartBloc, CartState>(
listener: (context, state) {},
child: TextButton(
style: TextButton.styleFrom(
disabledForegroundColor: theme.primaryColor,
),
onPressed: () => context.read<CartBloc>().add(CartItemRemoved(item)),
child:
const Icon(Icons.remove, semanticLabel: 'Remove'),
));
}
}
I hope this was useful to everyone who has followed , if you want to download the code please check