Build a Flutter E-Commerce UI — Complete Project Tutorial with Dart
In this tutorial, you will learn about Build a Flutter E. We cover key concepts, practical examples, and best practices to help you master this topic.
Build a complete Flutter e-commerce UI using Dart that showcases product listings with categories, a shopping cart with quantity controls, a checkout flow, and persistent state management using Provider and SharedPreferences.
What You Will Learn
- Designing a multi-screen e-commerce navigation structure
- Building product listing grids with filtering and sorting
- Implementing a shopping cart with add, remove, and quantity controls
- Managing cart state persistently with SharedPreferences
- Creating a checkout flow with address and payment forms
- Applying Material You theming and responsive layouts
- Handling empty states, loading states, and error states
Why It Matters
E-commerce apps represent one of the most complex and common mobile application categories. They combine product catalogs, search, filtering, cart management, user accounts, payments, and order tracking — all in a single app. Building an e-commerce UI teaches you patterns that apply to any data-driven app: list-detail navigation, state management across screens, form validation, and persistent local storage. Doda Browser's shopping assistant feature, which compares prices across retailers, reuses the same grid layout, cart model, and navigation architecture described in this project.
Real-World Use
A local grocery delivery service uses a Flutter e-commerce app where customers browse produce by category, add items to a cart, set delivery preferences, and complete checkout. The cart persists even if the app is closed, so the user can add items throughout the day and check out in one go. Durga Antivirus Pro's license store uses this same cart-and-checkout pattern to sell subscription plans.
Learning Path
flowchart LR A[Project Weather App] --> B[Project E-Commerce UI\nYou are here] B --> C[Project Game with Flame] style B fill:#f90,color:#fff
Project Setup
Create a new Flutter project:
flutter create ecommerce_ui
cd ecommerce_ui
Add dependencies:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.0
shared_preferences: ^2.2.0
cached_network_image: ^3.3.0
google_fonts: ^6.2.0
cached_network_image loads product images with Caching and placeholders. google_fonts provides typography options. shared_preferences persists the cart across app restarts.
Data Models
Define the product and cart models:
// lib/models/product.dart
class Product {
final String id;
final String name;
final String description;
final double price;
final String imageUrl;
final String category;
final double rating;
final int reviewCount;
final bool inStock;
Product({
required this.id,
required this.name,
required this.description,
required this.price,
required this.imageUrl,
required this.category,
this.rating = 0.0,
this.reviewCount = 0,
this.inStock = true,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String,
price: (json['price'] as num).toDouble(),
imageUrl: json['imageUrl'] as String? ?? 'https://via.placeholder.com/150',
category: json['category'] as String,
rating: (json['rating'] as num?)?.toDouble() ?? 0.0,
reviewCount: json['reviewCount'] as int? ?? 0,
inStock: json['inStock'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'description': description,
'price': price,
'imageUrl': imageUrl,
'category': category,
'rating': rating,
'reviewCount': reviewCount,
'inStock': inStock,
};
}
Product copyWith({
String? id,
String? name,
String? description,
double? price,
String? imageUrl,
String? category,
double? rating,
int? reviewCount,
bool? inStock,
}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
price: price ?? this.price,
imageUrl: imageUrl ?? this.imageUrl,
category: category ?? this.category,
rating: rating ?? this.rating,
reviewCount: reviewCount ?? this.reviewCount,
inStock: inStock ?? this.inStock,
);
}
}
// lib/models/cart_item.dart
import 'product.dart';
class CartItem {
final Product product;
int quantity;
CartItem({required this.product, this.quantity = 1});
double get totalPrice => product.price * quantity;
Map<String, dynamic> toJson() => {
'product': product.toJson(),
'quantity': quantity,
};
factory CartItem.fromJson(Map<String, dynamic> json) {
return CartItem(
product: Product.fromJson(json['product'] as Map<String, dynamic>),
quantity: json['quantity'] as int? ?? 1,
);
}
}
Cart Provider with Persistence
Manage the cart state and persist it to disk:
// lib/providers/cart_provider.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/product.dart';
import '../models/cart_item.dart';
class CartProvider extends ChangeNotifier {
List<CartItem> _items = [];
bool _isLoaded = false;
List<CartItem> get items => List.unmodifiable(_items);
int get itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
double get subtotal => _items.fold(0.0, (sum, item) => sum + item.totalPrice);
double get tax => subtotal * 0.08;
double get shipping => subtotal > 50 ? 0 : 5.99;
double get total => subtotal + tax + shipping;
bool get isEmpty => _items.isEmpty;
bool get isLoaded => _isLoaded;
Future<void> loadCart() async {
final prefs = await SharedPreferences.getInstance();
final cartJson = prefs.getString('cart');
if (cartJson != null) {
final list = jsonDecode(cartJson) as List<dynamic>;
_items = list.map((e) => CartItem.fromJson(e as Map<String, dynamic>)).toList();
}
_isLoaded = true;
notifyListeners();
}
Future<void> _saveCart() async {
final prefs = await SharedPreferences.getInstance();
final cartJson = jsonEncode(_items.map((e) => e.toJson()).toList());
await prefs.setString('cart', cartJson);
}
void addItem(Product product) {
final existing = _items.where((item) => item.product.id == product.id).firstOrNull;
if (existing != null) {
existing.quantity++;
} else {
_items.add(CartItem(product: product));
}
notifyListeners();
_saveCart();
}
void removeItem(String productId) {
_items.removeWhere((item) => item.product.id == productId);
notifyListeners();
_saveCart();
}
void updateQuantity(String productId, int quantity) {
final item = _items.where((item) => item.product.id == productId).firstOrNull;
if (item == null) return;
if (quantity <= 0) {
removeItem(productId);
return;
}
item.quantity = quantity;
notifyListeners();
_saveCart();
}
void clearCart() {
_items.clear();
notifyListeners();
_saveCart();
}
}
The cart is saved to SharedPreferences as a JSON string every time it changes. When the app starts, loadCart() restores the previous state.
Product Repository
Provide sample product data and filtering:
// lib/data/products.dart
import '../models/product.dart';
class ProductRepository {
static final List<Product> _allProducts = [
Product(
id: '1',
name: 'Wireless Headphones',
description: 'Premium noise-cancelling wireless headphones with 30-hour battery life and comfortable over-ear design.',
price: 79.99,
imageUrl: 'https://via.placeholder.com/300x300?text=Headphones',
category: 'Electronics',
rating: 4.5,
reviewCount: 234,
),
Product(
id: '2',
name: 'Running Shoes',
description: 'Lightweight running shoes with responsive cushioning and breathable mesh upper for daily training.',
price: 129.99,
imageUrl: 'https://via.placeholder.com/300x300?text=Shoes',
category: 'Sports',
rating: 4.3,
reviewCount: 189,
),
Product(
id: '3',
name: 'Cotton T-Shirt',
description: 'Classic fit cotton t-shirt available in multiple colors. Machine washable and pre-shrunk.',
price: 24.99,
imageUrl: 'https://via.placeholder.com/300x300?text=TShirt',
category: 'Clothing',
rating: 4.1,
reviewCount: 567,
),
Product(
id: '4',
name: 'Coffee Maker',
description: '12-cup programmable coffee maker with built-in grinder, thermal carafe, and auto-shutoff.',
price: 89.99,
imageUrl: 'https://via.placeholder.com/300x300?text=Coffee',
category: 'Home',
rating: 4.6,
reviewCount: 412,
),
Product(
id: '5',
name: 'Backpack',
description: 'Durable 35L backpack with padded laptop compartment, water bottle pockets, and ergonomic straps.',
price: 59.99,
imageUrl: 'https://via.placeholder.com/300x300?text=Backpack',
category: 'Accessories',
rating: 4.4,
reviewCount: 328,
),
Product(
id: '6',
name: 'Smart Watch',
description: 'Fitness smart watch with heart rate monitoring, GPS tracking, and 7-day battery life.',
price: 199.99,
imageUrl: 'https://via.placeholder.com/300x300?text=Watch',
category: 'Electronics',
rating: 4.2,
reviewCount: 876,
),
];
static List<Product> getAll() => List.unmodifiable(_allProducts);
static List<String> getCategories() {
return _allProducts.map((p) => p.category).toSet().toList()..sort();
}
static List<Product> getByCategory(String category) {
return _allProducts.where((p) => p.category == category).toList();
}
static Product? getById(String id) {
return _allProducts.where((p) => p.id == id).firstOrNull;
}
static List<Product> search(String query) {
if (query.isEmpty) return _allProducts;
final lower = query.toLowerCase();
return _allProducts
.where((p) => p.name.toLowerCase().contains(lower) || p.description.toLowerCase().contains(lower))
.toList();
}
}
Main App and Navigation
Set up the app with bottom navigation for home, categories, cart, and profile:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/cart_provider.dart';
import 'screens/home_screen.dart';
import 'screens/cart_screen.dart';
import 'screens/categories_screen.dart';
import 'screens/profile_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(
ChangeNotifierProvider(
create: (_) => CartProvider()..loadCart(),
child: MaterialApp(
title: 'ShopFlutter',
theme: ThemeData(
colorSchemeSeed: Colors.teal,
useMaterial3: true,
),
home: MainScreen(),
),
),
);
}
class MainScreen extends StatefulWidget {
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen> {
int _currentIndex = 0;
final screens = [
HomeScreen(),
CategoriesScreen(),
CartScreen(),
ProfileScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) => setState(() => _currentIndex = index),
destinations: [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.category_outlined), selectedIcon: Icon(Icons.category), label: 'Categories'),
NavigationDestination(
icon: Badge(
isLabelVisible: context.watch<CartProvider>().itemCount > 0,
label: Text('${context.watch<CartProvider>().itemCount}'),
child: Icon(Icons.shopping_cart_outlined),
),
selectedIcon: Badge(
isLabelVisible: context.watch<CartProvider>().itemCount > 0,
label: Text('${context.watch<CartProvider>().itemCount}'),
child: Icon(Icons.shopping_cart),
),
label: 'Cart',
),
NavigationDestination(icon: Icon(Icons.person_outlined), selectedIcon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
}
Home Screen with Product Grid
Build the product listing:
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import '../data/products.dart';
import '../models/product.dart';
import '../widgets/product_card.dart';
import 'product_detail_screen.dart';
class HomeScreen extends StatefulWidget {
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
final _searchController = TextEditingController();
String _searchQuery = '';
String _selectedCategory = 'All';
List<Product> get _filteredProducts {
var products = ProductRepository.getAll();
if (_selectedCategory != 'All') {
products = products.where((p) => p.category == _selectedCategory).toList();
}
if (_searchQuery.isNotEmpty) {
products = products
.where((p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase()))
.toList();
}
return products;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final categories = ['All', ...ProductRepository.getCategories()];
return Scaffold(
appBar: AppBar(title: Text('ShopFlutter')),
body: Column(
children: [
Padding(
padding: EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
),
onChanged: (query) => setState(() => _searchQuery = query),
),
),
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
children: categories.map((category) {
final isSelected = category == _selectedCategory;
return Padding(
padding: EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(category),
selected: isSelected,
onSelected: (_) => setState(() => _selectedCategory = category),
),
);
}).toList(),
),
),
SizedBox(height: 8),
Expanded(
child: _filteredProducts.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No products found'),
],
),
)
: GridView.builder(
padding: EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.7,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
return ProductCard(
product: _filteredProducts[index],
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProductDetailScreen(product: _filteredProducts[index]),
),
),
);
},
),
),
],
),
);
}
}
Product card widget:
// lib/widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:provider/provider.dart';
import '../models/product.dart';
import '../providers/cart_provider.dart';
class ProductCard extends StatelessWidget {
final Product product;
final VoidCallback onTap;
const ProductCard({required this.product, required this.onTap});
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: CachedNetworkImage(
imageUrl: product.imageUrl,
fit: BoxFit.cover,
width: double.infinity,
placeholder: (_, __) => Center(child: CircularProgressIndicator()),
errorWidget: (_, __, ___) => Center(child: Icon(Icons.image)),
),
),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
SizedBox(height: 4),
Row(
children: [
Icon(Icons.star, size: 14, color: Colors.amber),
SizedBox(width: 4),
Text('${product.rating}'),
],
),
SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'\$${product.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: Icon(Icons.add_shopping_cart, size: 20),
onPressed: () {
context.read<CartProvider>().addItem(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${product.name} added to cart'),
duration: Duration(seconds: 1),
),
);
},
),
],
),
],
),
),
],
),
),
);
}
}
Cart Screen
Build the shopping cart view with quantity controls:
// lib/screens/cart_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/cart_provider.dart';
import '../widgets/cart_item_card.dart';
import 'checkout_screen.dart';
class CartScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Shopping Cart')),
body: Consumer<CartProvider>(
builder: (context, cart, child) {
if (!cart.isLoaded) {
return Center(child: CircularProgressIndicator());
}
if (cart.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Your cart is empty', style: Theme.of(context).textTheme.headlineSmall),
SizedBox(height: 8),
Text('Browse products and add items to get started'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text('Start Shopping'),
),
],
),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.all(16),
itemCount: cart.items.length,
itemBuilder: (context, index) {
return CartItemCard(cartItem: cart.items[index]);
},
),
),
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [BoxShadow(blurRadius: 4, color: Colors.black26)],
),
child: Column(
children: [
_OrderSummaryRow(label: 'Subtotal', value: '\$${cart.subtotal.toStringAsFixed(2)}'),
_OrderSummaryRow(label: 'Tax (8%)', value: '\$${cart.tax.toStringAsFixed(2)}'),
_OrderSummaryRow(label: 'Shipping', value: cart.shipping == 0 ? 'Free' : '\$${cart.shipping.toStringAsFixed(2)}'),
Divider(),
_OrderSummaryRow(label: 'Total', value: '\$${cart.total.toStringAsFixed(2)}', isBold: true),
SizedBox(height: 16),
FilledButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => CheckoutScreen()),
),
icon: Icon(Icons.lock),
label: Text('Proceed to Checkout'),
style: FilledButton.styleFrom(
minimumSize: Size(double.infinity, 56),
),
),
],
),
),
],
);
},
),
);
}
}
class _OrderSummaryRow extends StatelessWidget {
final String label;
final String value;
final bool isBold;
const _OrderSummaryRow({required this.label, required this.value, this.isBold = false});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: isBold ? TextStyle(fontWeight: FontWeight.bold) : null),
Text(value, style: isBold ? TextStyle(fontWeight: FontWeight.bold) : null),
],
),
);
}
}
Cart item card with quantity controls:
// lib/widgets/cart_item_card.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/cart_item.dart';
import '../providers/cart_provider.dart';
class CartItemCard extends StatelessWidget {
final CartItem cartItem;
const CartItemCard({required this.cartItem});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.only(bottom: 12),
child: Padding(
padding: EdgeInsets.all(12),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
cartItem.product.imageUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cartItem.product.name, style: Theme.of(context).textTheme.titleSmall),
SizedBox(height: 4),
Text('\$${cartItem.product.price.toStringAsFixed(2)}'),
SizedBox(height: 8),
Row(
children: [
IconButton(
icon: Icon(Icons.remove_circle_outline),
onPressed: () {
context.read<CartProvider>().updateQuantity(
cartItem.product.id,
cartItem.quantity - 1,
);
},
iconSize: 20,
),
Text('${cartItem.quantity}'),
IconButton(
icon: Icon(Icons.add_circle_outline),
onPressed: () {
context.read<CartProvider>().updateQuantity(
cartItem.product.id,
cartItem.quantity + 1,
);
},
iconSize: 20,
),
],
),
],
),
),
Column(
children: [
Text('\$${cartItem.totalPrice.toStringAsFixed(2)}', style: TextStyle(fontWeight: FontWeight.bold)),
IconButton(
icon: Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => context.read<CartProvider>().removeItem(cartItem.product.id),
),
],
),
],
),
),
);
}
}
Common Mistakes
Storing complex objects in SharedPreferences: SharedPreferences is designed for simple key-value pairs. For complex data like cart items, serialize to JSON first. For large datasets, consider sqflite instead.
Not using immutable state in provider: Mutating a
CartItem's quantity directly without callingnotifyListeners()causes the UI to display stale data. Always callnotifyListeners()after state mutations.Hard-coding product data: Product data in this tutorial is static for simplicity. In production, products come from an API. Abstract the
ProductRepositorybehind an interface so you can swap the implementation without changing the UI code.Forgetting to dispose controllers: Every
TextEditingControllermust be disposed. Failing to do so causes memory leaks that slow down the app over time.Ignoring empty and error states: An empty cart or failed product load should show a helpful state rather than a blank screen. Always handle loading, empty, and error states for every data-driven widget.
Not using placeholder images: Product images that fail to load leave broken image icons. Use
cached_network_imagewitherrorWidgetandplaceholderparameters to handle both loading and error states gracefully.Blocking the UI during cart persistence:
SharedPreferencesoperations are asynchronous in Flutter. Callawaitand show a loading indicator if the cart is large enough to cause noticeable delay.
Practice Questions
- Why is the cart data persisted to SharedPreferences as a JSON string rather than as individual key-value pairs?
- How does the
Consumer<CartProvider>widget prevent unnecessary rebuilds in the navigation bar badge? - What changes would be needed to support multiple currency formats?
- How would you implement a wishlist feature alongside the cart?
- Challenge: Add a coupon code system. Create a
CouponProviderthat validates coupon codes against a list of active coupons, calculates discounts, and applies them to the cart total before checkout. Handle expired and invalid coupon codes with appropriate error messages.
Mini Project
Extend the e-commerce UI with these features:
- Product detail screen with image gallery (swipeable), size/color selection, reviews list, and related products
- Order history screen showing past orders with status tracking
- Address management screen with Google Maps address autocomplete
- Dark mode toggle persisted in SharedPreferences
- Search with debounced text input and recent search suggestions
- Animated cart badge that scales when items are added using
TweenAnimationBuilder
FAQ
What is Next
Proceed to Project Game with Flame to build a 2D game using the Flame game engine, applying animation, collision detection, and input handling skills. Then revisit State Management for more advanced patterns used in complex apps.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro