Building a Shopping Cart Widget with Discount, Summary, and Checkout in Flutter
A robust shopping cart is a fundamental component of any e-commerce application. It allows users to manage selected products, review prices, apply discounts, and proceed to checkout. Building a feature-rich shopping cart in Flutter provides a highly customizable and performant solution. This article will guide you through creating a dynamic shopping cart widget, incorporating discount application, a clear summary, and a functional checkout process.
1. Defining the Data Models
First, let's establish the necessary data models for our products and cart items. We'll also define a simple discount model.
Product Model
class Product {
final String id;
final String name;
final double price;
final String imageUrl;
Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
});
}
Cart Item Model
Each item in the cart will reference a Product and track its quantity.
class CartItem {
final Product product;
int quantity;
CartItem({
required this.product,
this.quantity = 1,
});
double get totalPrice => product.price * quantity;
}
Discount Model
A simple model for applying discounts, either by percentage or a fixed amount.
enum DiscountType { percentage, fixed }
class Discount {
final String code;
final double value; // Percentage (e.g., 0.10 for 10%) or fixed amount
final DiscountType type;
Discount({
required this.code,
required this.value,
required this.type,
});
}
2. State Management with Provider
For managing the cart's state, we'll use the provider package. This CartProvider class will hold the cart items, apply discounts, and calculate totals.
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart'; // For firstWhereOrNull
class CartProvider with ChangeNotifier {
final List<CartItem> _items = [];
Discount? _appliedDiscount;
List<CartItem> get items => [..._items]; // Immutable copy
Discount? get appliedDiscount => _appliedDiscount;
// Getters for calculations
double get subtotal {
return _items.fold(0.0, (sum, item) => sum + item.totalPrice);
}
double get discountAmount {
if (_appliedDiscount == null) return 0.0;
if (_appliedDiscount!.type == DiscountType.percentage) {
return subtotal * _appliedDiscount!.value;
} else {
return _appliedDiscount!.value; // Fixed amount
}
}
double get total => subtotal - discountAmount;
// Cart actions
void addItem(Product product) {
final existingItem = _items.firstWhereOrNull(
(item) => item.product.id == product.id,
);
if (existingItem != null) {
existingItem.quantity++;
} else {
_items.add(CartItem(product: product));
}
notifyListeners();
}
void removeItem(String productId) {
_items.removeWhere((item) => item.product.id == productId);
notifyListeners();
}
void updateItemQuantity(String productId, int newQuantity) {
final existingItem = _items.firstWhereOrNull(
(item) => item.product.id == productId,
);
if (existingItem != null) {
if (newQuantity > 0) {
existingItem.quantity = newQuantity;
} else {
_items.removeWhere((item) => item.product.id == productId);
}
notifyListeners();
}
}
void applyDiscount(String discountCode) {
// In a real app, you would fetch this from a backend.
// For this example, we'll use a hardcoded list.
final List<Discount> availableDiscounts = [
Discount(code: 'SAVE10', value: 0.10, type: DiscountType.percentage),
Discount(code: 'FLAT5', value: 5.0, type: DiscountType.fixed),
];
final discount = availableDiscounts.firstWhereOrNull(
(d) => d.code.toLowerCase() == discountCode.toLowerCase(),
);
if (discount != null) {
_appliedDiscount = discount;
print('Discount ${discount.code} applied!');
} else {
_appliedDiscount = null; // Clear previous discount if invalid
print('Invalid discount code.');
}
notifyListeners();
}
void removeDiscount() {
_appliedDiscount = null;
notifyListeners();
}
void clearCart() {
_items.clear();
_appliedDiscount = null;
notifyListeners();
}
}
3. Building the Cart Item Widget
Each item in the shopping cart will be represented by a CartItemWidget. This widget displays product details and allows users to modify quantities or remove the item.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume your models and provider are imported here
class CartItemWidget extends StatelessWidget {
final CartItem cartItem;
const CartItemWidget({Key? key, required this.cartItem}) : super(key: key);
@override
Widget build(BuildContext context) {
final cart = Provider.of<CartProvider>(context, listen: false);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: Image.network(
cartItem.product.imageUrl,
fit: BoxFit.cover,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
cartItem.product.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text('\$${cartItem.product.price.toStringAsFixed(2)}'),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () {
cart.updateItemQuantity(
cartItem.product.id,
cartItem.quantity - 1,
);
},
),
Text('${cartItem.quantity}'),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () {
cart.updateItemQuantity(
cartItem.product.id,
cartItem.quantity + 1,
);
},
),
const Spacer(),
Text(
'\$${cartItem.totalPrice.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
],
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () {
cart.removeItem(cartItem.product.id);
},
),
],
),
),
);
}
}
4. Implementing Discount Logic and Input
We'll create a section where users can input a discount code and apply it. The CartProvider handles the actual logic and calculation.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume CartProvider is imported
class DiscountInputWidget extends StatefulWidget {
const DiscountInputWidget({Key? key}) : super(key: key);
@override
State<DiscountInputWidget> createState() => _DiscountInputWidgetState();
}
class _DiscountInputWidgetState extends State<DiscountInputWidget> {
final TextEditingController _discountController = TextEditingController();
@override
void dispose() {
_discountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cart = Provider.of<CartProvider>(context);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Have a discount code?',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: _discountController,
decoration: InputDecoration(
hintText: 'Enter discount code',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () {
cart.applyDiscount(_discountController.text);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Apply'),
),
],
),
if (cart.appliedDiscount != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Text(
'Applied: ${cart.appliedDiscount!.code}',
style: const TextStyle(color: Colors.green),
),
const Spacer(),
TextButton(
onPressed: () {
cart.removeDiscount();
_discountController.clear();
},
child: const Text('Remove', style: TextStyle(color: Colors.red)),
),
],
),
),
],
),
);
}
}
5. Creating the Cart Summary Widget
The cart summary displays the breakdown of costs: subtotal, discount, and the final total. This widget will listen to changes in the CartProvider to update automatically.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume CartProvider is imported
class CartSummaryWidget extends StatelessWidget {
const CartSummaryWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<CartProvider>(
builder: (context, cart, child) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Order Summary',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Divider(),
_buildSummaryRow('Subtotal:', cart.subtotal),
if (cart.appliedDiscount != null)
_buildSummaryRow(
'Discount (${cart.appliedDiscount!.code}):',
-cart.discountAmount, // Show as negative for discount
color: Colors.green,
),
const Divider(),
_buildSummaryRow(
'Total:',
cart.total,
isBold: true,
fontSize: 18,
),
],
),
),
);
},
);
}
Widget _buildSummaryRow(String label, double amount,
{Color? color, bool isBold = false, double fontSize = 16}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
color: color,
),
),
Text(
'\$${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: fontSize,
fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
color: color,
),
),
],
),
);
}
}
6. Developing the Checkout Process
The checkout button will trigger a simple action. In a real application, this would lead to payment processing, address selection, etc. For this example, we'll just show a confirmation message and clear the cart.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume CartProvider is imported
class CheckoutButton extends StatelessWidget {
const CheckoutButton({Key? key}) : super(key: key);
void _performCheckout(BuildContext context) {
final cart = Provider.of<CartProvider>(context, listen: false);
if (cart.items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Your cart is empty!')),
);
return;
}
// Simulate a checkout process
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Order Placed!'),
content: Text(
'Your order for \$${cart.total.toStringAsFixed(2)} has been placed successfully.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
cart.clearCart(); // Clear the cart after successful checkout
},
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _performCheckout(context),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('Proceed to Checkout'),
),
),
);
}
}
7. Assembling the Main Cart Screen
Now, let's put all these components together into a complete CartScreen.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume all models, CartProvider, CartItemWidget, DiscountInputWidget,
// CartSummaryWidget, and CheckoutButton are imported.
class CartScreen extends StatelessWidget {
const CartScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Your Shopping Cart'),
),
body: Consumer<CartProvider>(
builder: (context, cart, child) {
if (cart.items.isEmpty) {
return const Center(
child: Text(
'Your cart is empty!',
style: TextStyle(fontSize: 18, color: Colors.grey),
),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) {
return CartItemWidget(cartItem: cart.items[index]);
},
),
),
const DiscountInputWidget(),
const CartSummaryWidget(),
const CheckoutButton(),
],
);
},
),
);
}
}
Integrating into your Flutter App
To use this, wrap your app or a relevant part of it with a ChangeNotifierProvider:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// Assume CartProvider and CartScreen are imported
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => CartProvider(),
child: MaterialApp(
title: 'Flutter Shopping Cart',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: const ProductListingScreen(), // Or whichever screen leads to your cart
),
);
}
}
// Example ProductListingScreen to add items to cart
class ProductListingScreen extends StatelessWidget {
const ProductListingScreen({super.key});
final List<Product> sampleProducts = const [
Product(id: 'p1', name: 'Laptop', price: 1200.00, imageUrl: 'https://via.placeholder.com/150/FF0000/FFFFFF?text=Laptop'),
Product(id: 'p2', name: 'Mouse', price: 25.00, imageUrl: 'https://via.placeholder.com/150/0000FF/FFFFFF?text=Mouse'),
Product(id: 'p3', name: 'Keyboard', price: 75.00, imageUrl: 'https://via.placeholder.com/150/00FF00/FFFFFF?text=Keyboard'),
];
@override
Widget build(BuildContext context) {
final cart = Provider.of<CartProvider>(context, listen: false);
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
actions: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CartScreen()),
);
},
),
],
),
body: ListView.builder(
itemCount: sampleProducts.length,
itemBuilder: (context, index) {
final product = sampleProducts[index];
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
leading: Image.network(product.imageUrl, width: 50, height: 50, fit: BoxFit.cover),
title: Text(product.name),
subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
trailing: IconButton(
icon: const Icon(Icons.add_shopping_cart),
onPressed: () {
cart.addItem(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart!')),
);
},
),
),
);
},
),
);
}
}
Conclusion
You have successfully built a functional shopping cart widget in Flutter, complete with item management, discount application, a clear order summary, and a basic checkout flow. By utilizing provider for state management, the application remains organized and responsive to user interactions.
Further Enhancements:
- **Persistent Storage:** Save cart items locally using
shared_preferencesor a database. - **Backend Integration:** Fetch products, validate discounts, and process orders through a backend API.
- **Animations:** Add subtle animations for item additions or removals.
- **Error Handling:** Implement robust error handling for discount codes and checkout failures.
- **Payment Gateway:** Integrate with actual payment gateways (e.g., Stripe, PayPal).
This foundation can be extended and customized to fit the specific needs of any e-commerce application.