Add to Cart + Cart Logic (Provider)
Tujuan Pembelajaran
Siswa mampu:
1. Menambahkan product ke keranjang (Add to Cart) dari halaman product.
2. Menampilkan keranjang (Cart Page) beserta total harga.
3. Membuat logic cart yang umum dipakai di aplikasi nyata:
- tambah qty / kurang qty
- hapus item
- validasi qty tidak boleh < 1
- validasi stok (opsional/dummy)
- validasi keranjang kosong
4. Menambahkan tombol Checkout di bawah cart (nanti lanjut ke Transaction/Checkout pada pertemuan berikutnya).
Pertanyaan Pemantik
- Kalau user menekan “Add to Cart” berkali-kali, seharusnya terjadi apa?
- Lebih baik cart menyimpan Product lengkap atau hanya productId + qty? Kenapa?
- Apa risiko jika cart tidak punya validasi (misal qty bisa 0, minus, atau item kosong)?
- Saat checkout, data apa yang perlu dibawa dari cart?
Konsep Inti (Singkat)
1) Cart menyimpan data minimal
Untuk pola clean code dan siap API:
- Cart cukup menyimpan productId dan qty.
- Harga/nama/gambar diambil dari ProductProvider (single source of truth).
Keuntungan:
- Jika product berubah (misal harga), cart otomatis ikut menyesuaikan.
2) Provider sebagai sumber kebenaran
- Semua update cart (tambah, kurang, hapus) lewat
CartProvider. - UI tidak mengubah list/cart secara langsung.
3) Validasi yang wajib
- Cart kosong: tampilkan UI kosong + tombol kembali belanja.
- Qty tidak boleh < 1.
- Item yang productnya tidak ditemukan: tampilkan fallback (atau hapus otomatis).
- Checkout disabled kalau cart kosong.
Praktik Utama (Step-by-step)
Struktur Folder
Tambahkan file berikut:
lib/
├── models/
│ └── cart_item.dart
├── providers/
│ └── cart_provider.dart
├── pages/
│ └── cart_page.dart
└── routes/
├── app_routes.dart
└── app_router.dart
Catatan: Jika sebelumnya sudah ada CartProvider, Anda bisa sesuaikan/rapikan agar mengikuti struktur di bawah.
Step 1 — Model CartItem
File: lib/models/cart_item.dart
class CartItem {
final String productId;
final int qty;
const CartItem({
required this.productId,
required this.qty,
});
CartItem copyWith({int? qty}) {
return CartItem(
productId: productId,
qty: qty ?? this.qty,
);
}
}
Step 2 — CartProvider (Logic inti cart)
File: lib/providers/cart_provider.dart
import 'package:flutter/material.dart';
import '../models/cart_item.dart';
class CartProvider extends ChangeNotifier {
// Map: key = productId, value = CartItem
final Map<String, CartItem> _items = {};
// Read-only untuk UI
List<CartItem> get items => _items.values.toList();
bool get isEmpty => _items.isEmpty;
int get totalItems => _items.values.fold(0, (sum, it) => sum + it.qty);
// ADD: jika sudah ada -> qty +1, jika belum -> buat baru qty 1
void addItem(String productId) {
final existing = _items[productId];
if (existing == null) {
_items[productId] = CartItem(productId: productId, qty: 1);
} else {
_items[productId] = existing.copyWith(qty: existing.qty + 1);
}
notifyListeners();
}
// ADD dengan qty (misal dari form)
void addItemWithQty(String productId, int qty) {
if (qty <= 0) return;
final existing = _items[productId];
if (existing == null) {
_items[productId] = CartItem(productId: productId, qty: qty);
} else {
_items[productId] = existing.copyWith(qty: existing.qty + qty);
}
notifyListeners();
}
// INCREMENT qty
void increaseQty(String productId) {
final existing = _items[productId];
if (existing == null) return;
_items[productId] = existing.copyWith(qty: existing.qty + 1);
notifyListeners();
}
// DECREMENT qty (validasi: jika jadi 0 -> hapus)
void decreaseQty(String productId) {
final existing = _items[productId];
if (existing == null) return;
final next = existing.qty - 1;
if (next <= 0) {
_items.remove(productId);
} else {
_items[productId] = existing.copyWith(qty: next);
}
notifyListeners();
}
// SET qty (validasi: qty minimal 1)
void setQty(String productId, int qty) {
final existing = _items[productId];
if (existing == null) return;
if (qty <= 0) {
_items.remove(productId);
} else {
_items[productId] = existing.copyWith(qty: qty);
}
notifyListeners();
}
// REMOVE item
void removeItem(String productId) {
_items.remove(productId);
notifyListeners();
}
// CLEAR cart
void clear() {
_items.clear();
notifyListeners();
}
}
Catatan logic yang umum di industri
addItem: kalau sudah ada, qty bertambah (bukan menambah baris baru).decreaseQty: jika qty jadi 0, item dihapus otomatis.clear: dipakai setelah checkout sukses.
Step 3 — Tambahkan Provider di main.dart
Jika Anda menggunakan MultiProvider, tambahkan:
File: lib/main.dart
ChangeNotifierProvider(create: (_) => CartProvider()),
Pastikan urutan provider tidak masalah, yang penting terdaftar.
Step 4 — Tambahkan Route Cart
Tambahkan route ke /cart (kalau belum ada).
File: lib/routes/app_routes.dart
static const cartName = 'cart';
static const cartPath = '/cart';
File: lib/routes/app_router.dart
Tambahkan GoRoute:
GoRoute(
name: AppRoutes.cartName,
path: AppRoutes.cartPath,
builder: (context, state) => const CartPage(),
),
Step 5 — Add to Cart dari Product Card/List
Di halaman product, pada tombol “Add to Cart”, panggil provider:
Contoh di widget ProductCard:
onPressed: () {
context.read<CartProvider>().addItem(product.id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produk ditambahkan ke cart')),
);
},
Jika Anda punya tombol icon cart di appbar:
IconButton(
onPressed: () => context.pushNamed(AppRoutes.cartName),
icon: const Icon(Icons.shopping_cart_outlined),
),
Cart Page (UI + Validasi Kosong + Total + Checkout)
Step 6 — CartPage
File: lib/pages/cart_page.dart
Catatan: - UI menampilkan: - daftar item - tombol + / - - delete - total harga - tombol checkout di bawah - Validasi: - jika cart kosong -> UI empty state + tombol “Belanja”
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../providers/cart_provider.dart';
import '../providers/product_provider.dart';
import '../routes/app_routes.dart';
class CartPage extends StatelessWidget {
const CartPage({super.key});
String rp(int n) => 'Rp $n';
int subtotal(BuildContext context) {
final cart = context.read<CartProvider>();
final products = context.read<ProductProvider>();
int total = 0;
for (final item in cart.items) {
final p = products.getById(item.productId);
if (p == null) continue;
total += p.price * item.qty;
}
return total;
}
Future<void> confirmDelete(BuildContext context, String productId) async {
final ok = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Hapus Item'),
content: const Text('Yakin ingin menghapus item ini dari cart?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Batal')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Hapus')),
],
),
);
if (ok == true) {
context.read<CartProvider>().removeItem(productId);
}
}
@override
Widget build(BuildContext context) {
final cart = context.watch<CartProvider>();
final products = context.read<ProductProvider>();
final total = subtotal(context);
if (cart.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Cart')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shopping_cart_outlined, size: 56),
const SizedBox(height: 12),
const Text(
'Keranjang masih kosong',
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 18),
),
const SizedBox(height: 6),
const Text('Ayo pilih product dulu, lalu tambahkan ke cart.'),
const SizedBox(height: 16),
SizedBox(
width: 220,
height: 44,
child: ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Belanja'),
),
),
],
),
),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Cart'),
actions: [
TextButton(
onPressed: () => context.read<CartProvider>().clear(),
child: const Text('Clear'),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
...cart.items.map((item) {
final p = products.getById(item.productId);
if (p == null) {
// Validasi: product tidak ditemukan (data rusak)
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.red.withOpacity(0.08),
),
child: Row(
children: [
const Expanded(child: Text('Item tidak ditemukan (hapus item ini).')),
IconButton(
onPressed: () => context.read<CartProvider>().removeItem(item.productId),
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
p.imageUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 56,
height: 56,
alignment: Alignment.center,
color: Colors.black12,
child: const Icon(Icons.broken_image_outlined),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name, style: const TextStyle(fontWeight: FontWeight.w900)),
const SizedBox(height: 2),
Text('${p.category} • ${rp(p.price)}', style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)),
const SizedBox(height: 6),
Text('Subtotal: ${rp(p.price * item.qty)}', style: const TextStyle(fontWeight: FontWeight.w700)),
],
),
),
const SizedBox(width: 8),
Column(
children: [
IconButton(
tooltip: 'Tambah',
onPressed: () => context.read<CartProvider>().increaseQty(item.productId),
icon: const Icon(Icons.add_circle_outline),
),
Text(item.qty.toString(), style: const TextStyle(fontWeight: FontWeight.w900)),
IconButton(
tooltip: 'Kurang',
onPressed: () => context.read<CartProvider>().decreaseQty(item.productId),
icon: const Icon(Icons.remove_circle_outline),
),
],
),
IconButton(
tooltip: 'Delete',
onPressed: () => confirmDelete(context, item.productId),
icon: const Icon(Icons.delete_outline),
),
],
),
);
}),
const SizedBox(height: 90),
],
),
bottomNavigationBar: SafeArea(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(top: BorderSide(color: Theme.of(context).colorScheme.outlineVariant)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('Total', style: TextStyle(fontWeight: FontWeight.w900)),
const Spacer(),
Text(rp(total), style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 16)),
],
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: cart.isEmpty
? null
: () {
// Nanti diarahkan ke halaman checkout/transaction pada pertemuan berikutnya
// Jika sudah punya route:
// context.pushNamed(AppRoutes.transactionName);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lanjut ke Checkout/Transaction (pertemuan berikutnya)')),
);
},
child: const Text('Checkout'),
),
),
],
),
),
),
);
}
}
Studi Kasus (Praktik)
Buat alur berikut: 1. Di halaman product, pilih minimal 3 product. 2. Tambahkan beberapa kali product yang sama. 3. Masuk ke cart: - Pastikan qty bertambah (bukan duplikat baris). - Coba kurangi qty sampai 0, pastikan item hilang. 4. Tekan tombol clear, pastikan cart kosong. 5. Saat cart kosong, tombol checkout harus nonaktif.
Tugas (Tanpa Kode Jawaban)
Tugas Dasar
- Tambahkan badge jumlah item di icon cart (misal di appbar). Jika
totalItems = 0, badge tidak tampil. - Buat validasi maksimal qty: misal qty maksimal 10 per item. Jika lebih, tampilkan SnackBar “Maksimal 10”.
Tugas Industri (Lebih Sulit)
- Buat fitur “catatan per item” (notes) di cart (dummy). Contoh: “Warna hitam”, “Ukuran XL”.
- Buat fitur “select all / unselect” untuk item yang akan dicheckout (tidak semua item harus masuk checkout).
- Buat perhitungan pajak (PPN dummy 11%) dan tampilkan breakdown: subtotal, pajak, total.
Refleksi
- Apa keuntungan cart menyimpan
productIddanqtysaja? - Bagaimana cara menghindari bug qty minus?
- Jika product dihapus dari data utama, apa yang harus terjadi di cart?
- Bagian mana dari cart yang paling sering berubah ketika pindah dari dummy ke API/Firebase?