Lewati ke isi

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

  1. Kalau user menekan “Add to Cart” berkali-kali, seharusnya terjadi apa?
  2. Lebih baik cart menyimpan Product lengkap atau hanya productId + qty? Kenapa?
  3. Apa risiko jika cart tidak punya validasi (misal qty bisa 0, minus, atau item kosong)?
  4. 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

  1. Tambahkan badge jumlah item di icon cart (misal di appbar). Jika totalItems = 0, badge tidak tampil.
  2. Buat validasi maksimal qty: misal qty maksimal 10 per item. Jika lebih, tampilkan SnackBar “Maksimal 10”.

Tugas Industri (Lebih Sulit)

  1. Buat fitur “catatan per item” (notes) di cart (dummy). Contoh: “Warna hitam”, “Ukuran XL”.
  2. Buat fitur “select all / unselect” untuk item yang akan dicheckout (tidak semua item harus masuk checkout).
  3. Buat perhitungan pajak (PPN dummy 11%) dan tampilkan breakdown: subtotal, pajak, total.

Refleksi

  1. Apa keuntungan cart menyimpan productId dan qty saja?
  2. Bagaimana cara menghindari bug qty minus?
  3. Jika product dihapus dari data utama, apa yang harus terjadi di cart?
  4. Bagian mana dari cart yang paling sering berubah ketika pindah dari dummy ke API/Firebase?