Membangun Aplikasi E-Commerce dengan Flutter: State Management BLoC, Checkout Flow, dan Payment Gateway Integration
Aplikasi e-commerce mempertemukan banyak state global dalam satu tempat: keranjang belanja, autentikasi, alur checkout, dan status pembayaran. Setiap state ini berubah lintas halaman dan harus tetap sinkron di seluruh widget tree. BLoC (Business Logic Component) hadir sebagai solusi state management yang memaksa alur data berjalan satu arah, sehingga perubahan state selalu predictable dan mudah dilacak. Artikel ini membangun aplikasi e-commerce dengan Flutter dari keranjang belanja sampai integrasi payment gateway menggunakan pola BLoC.
Mengapa BLoC Menjadi Pilihan State Management untuk E-Commerce
BLoC bekerja dengan siklus sederhana: UI mengirimkan Event, bloc memprosesnya, lalu mengeluarkan State baru yang dirender kembali oleh UI. Aliran satu arah ini mencegah state diubah dari banyak tempat secara sembarangan, masalah yang sering terjadi ketika aplikasi bertumbuh dengan setState yang tersebar.
Untuk e-commerce, karakteristik ini sangat penting. Keranjang belanja bisa dimodifikasi dari halaman produk, halaman detail, dan halaman keranjang itu sendiri. Tanpa satu sumber kebenaran, total harga bisa berbeda antar halaman. BLoC menjadikan CartBloc sebagai satu-satunya tempat logika keranjang berjalan, dan setiap widget hanya membaca state darinya.
Gambar: Unidirectional data flow antara UI, Logic, dan Data layer — Sumber: [Flutter Docs](https://docs.flutter.dev/app-architecture/concepts)
Keunggulan lain adalah testability. Karena business logic terpisah dari widget, bloc dapat diuji secara unit tanpa memerlukan emulator atau widget testing. Kita cukup mengirimkan event dan memverifikasi state yang dihasilkan. Pendekatan ini juga memudahkan kolaborasi: UI developer fokus pada widget, sementara logic developer fokus pada bloc.
// Event mendeskripsikan aksi dari UI
sealed class CartEvent {}
class AddToCart extends CartEvent {
final Product product;
AddToCart(this.product);
}
class RemoveFromCart extends CartEvent {
final String productId;
RemoveFromCart(this.productId);
}
// State merepresentasikan kondisi keranjang saat ini
class CartState {
final List<CartItem> items;
final double totalPrice;
final int totalItems;
const CartState({
this.items = const [],
this.totalPrice = 0,
this.totalItems = 0,
});
}Pola sealed class untuk event memaksa kompiler memeriksa semua kemungkinan saat kita menulis handler. Dengan cara ini, menambahkan event baru tanpa menangani semua cabang akan menghasilkan error compile time, bukan bug runtime yang sulit dilacak.
Struktur Proyek dan Setup Dependensi yang Disiplin
Aplikasi e-commerce yang sehat dimulai dari struktur folder yang jelas. Pendekatan feature-first membagi proyek berdasarkan fitur, bukan berdasarkan tipe file. Setiap fitur seperti cart, checkout, dan product memiliki folder sendiri dengan layer data, domain, dan presentation di dalamnya.
Layer data menangani sumber data dari REST API, domain berisi model dan repository interface yang pure, sementara presentation berisi widget dan bloc. Pemisahan ini memastikan business logic tidak bergantung pada detail implementasi HTTP atau database, sehingga mudah diganti dan diuji.
Gambar: Tiga layer utama arsitektur aplikasi — UI layer, logic layer, dan data layer — Sumber: [Flutter Docs](https://docs.flutter.dev/app-architecture/concepts)
Dependensi inti yang dibutuhkan cukup minimal. flutter_bloc untuk state management, dio untuk HTTP client dengan interceptor, equatable untuk perbandingan state yang efisien, dan intl untuk memformat harga dalam format Rupiah.
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.6
dio: ^5.7.0
equatable: ^2.0.5
intl: ^0.19.0
hydrated_bloc: ^9.1.5Di level root aplikasi, setiap bloc didaftarkan melalui MultiBlocProvider. Provider ini menginjeksi bloc ke seluruh widget tree sehingga halaman mana pun dapat mengakses state tanpa perlu meneruskan instance secara manual melalui constructor.
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => CartBloc()),
BlocProvider(create: (_) => CheckoutBloc()),
],
child: const MaterialApp(
title: 'ShopKita',
home: HomePage(),
),
);
}
}Satu catatan penting: CartBloc dibuat dengan BlocProvider di level ini karena sifatnya global. Bloc lain yang hanya dipakai satu halaman, seperti ProductDetailBloc, cukup dideklarasikan di halaman tersebut agar lifecycle-nya mengikuti halaman.
Membangun State Keranjang Belanja yang Konsisten
Keranjang belanja adalah jantung aplikasi e-commerce. State-nya harus immutable: setiap perubahan menghasilkan instance CartState baru, bukan memutasi list yang ada. Prinsip ini mencegah bug halus seperti widget yang tidak rebuild karena referensi object yang sama.
CartBloc menangani empat peristiwa utama: menambah produk, menghapus produk, mengubah kuantitas, dan mengosongkan keranjang setelah checkout selesai. Setiap handler menghasilkan state baru dengan kalkulasi ulang total harga dan jumlah item.
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(const CartState()) {
on<AddToCart>(_onAddToCart);
on<RemoveFromCart>(_onRemoveFromCart);
on<UpdateQuantity>(_onUpdateQuantity);
on<ClearCart>((_, emit) => emit(const CartState()));
}
void _onAddToCart(AddToCart event, Emitter<CartState> emit) {
final existing = state.items
.where((item) => item.productId == event.product.id)
.firstOrNull;
final updatedItems = existing == null
? [...state.items, CartItem(product: event.product, quantity: 1)]
: state.items
.map((item) => item.productId == existing.productId
? item.copyWith(quantity: item.quantity + 1)
: item)
.toList();
emit(state.copyWith(items: updatedItems));
}
void _onRemoveFromCart(RemoveFromCart event, Emitter<CartState> emit) {
final updatedItems =
state.items.where((item) => item.productId != event.productId).toList();
emit(state.copyWith(items: updatedItems));
}
}Perhatikan pola copyWith pada state. Semua kalkulasi total dilakukan di dalam bloc, bukan di widget. CartState menyimpan totalPrice dan totalItems yang langsung dikonsumsi oleh UI melalui BlocBuilder, sehingga widget tidak perlu melakukan perhitungan ulang dan risiko inkonsistensi antar halaman hilang.
Di sisi UI, context.watch<CartBloc>() dari package flutter_bloc membuat widget rebuild otomatis setiap kali state berubah. Badge jumlah item di app bar dan halaman keranjang membaca state yang sama, memastikan keduanya selalu menampilkan angka yang identik.
Mendesain Alur Checkout Multi-Step dengan CheckoutBloc
Alur checkout pada aplikasi e-commerce nyata tidak pernah satu halaman. Pengguna melewati alamat pengiriman, memilih metode pengiriman, memilih metode pembayaran, lalu meninjau pesanan sebelum konfirmasi. Setiap langkah menyimpan data yang harus tersedia di langkah berikutnya.
CheckoutBloc mengelola seluruh perjalanan ini dalam satu state. Enum CheckoutStep merepresentasikan posisi saat ini, sementara data tiap langkah disimpan sebagai field terpisah. Validasi dilakukan sebelum transisi step, sehingga pengguna tidak bisa melanjutkan dengan data yang tidak lengkap.
enum CheckoutStep { address, shipping, payment, review }
class CheckoutState {
final CheckoutStep currentStep;
final ShippingAddress? address;
final ShippingMethod? shippingMethod;
final PaymentMethod? paymentMethod;
const CheckoutState({
this.currentStep = CheckoutStep.address,
this.address,
this.shippingMethod,
this.paymentMethod,
});
}
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
CheckoutBloc() : super(const CheckoutState()) {
on<NextStep>((event, emit) {
if (!_isCurrentStepValid(state)) return;
final steps = CheckoutStep.values;
final nextIndex = state.currentStep.index + 1;
if (nextIndex < steps.length) {
emit(state.copyWith(currentStep: steps[nextIndex]));
}
});
}
bool _isCurrentStepValid(CheckoutState state) {
return switch (state.currentStep) {
CheckoutStep.address => state.address != null,
CheckoutStep.shipping => state.shippingMethod != null,
CheckoutStep.payment => state.paymentMethod != null,
CheckoutStep.review => true,
};
}
}Pola ini membuat navigasi state-driven: halaman tidak berpindah melalui Navigator.push secara manual, melainkan merespons perubahan currentStep dari bloc. Keuntungannya, tombol back Android dan progress indicator membaca sumber data yang sama. Jika pengguna kembali ke langkah sebelumnya, data yang sudah diisi tetap tersimpan di state dan tidak hilang.
Untuk alur yang lebih kompleks seperti autentikasi sebelum checkout, bloc yang sama dapat menerima event tambahan RequestLogin dan menyimpan status login sebagai bagian dari state. Ini menjaga seluruh konteks transaksi tetap dalam satu tempat yang mudah dianalisis.
Integrasi Payment Gateway untuk Transaksi Nyata
Integrasi payment gateway adalah titik paling sensitif dalam aplikasi e-commerce. Prinsip pertama yang wajib dipegang: aplikasi mobile tidak pernah menyimpan atau membuat transaksi sendiri. Server yang memegang credential merchant membuat payment token, dan aplikasi hanya menampilkan SDK untuk menyelesaikan pembayaran.
Alur yang benar dimulai ketika pengguna menekan tombol bayar. Aplikasi mengirim data pesanan ke backend melalui dio, backend membuat transaksi di payment gateway, lalu mengembalikan token. Aplikasi membuka SDK payment gateway dengan token tersebut, pengguna menyelesaikan pembayaran, dan SDK mengembalikan hasil melalui callback.
class PaymentService {
final Dio _dio = Dio(BaseOptions(baseUrl: 'https://api.shopkita.com'));
Future<String> createPaymentToken(Order order) async {
final response = await _dio.post(
'/payments/create-token',
data: {
'orderId': order.id,
'amount': order.totalPrice,
'items': order.items.map((e) => e.toJson()).toList(),
},
);
return response.data['token'];
}
Future<PaymentResult> pay(Order order) async {
final token = await createPaymentToken(order);
final result = await MidtransSDK.shared
.startPaymentUiFlow(token: token)
.catchError((_) => PaymentResult.failed());
return result.transactionStatus == 'settlement'
? PaymentResult.success(result.transactionId)
: PaymentResult.pending();
}
}Tiga status utama transaksi perlu ditangani: pending saat pembayaran belum selesai, settlement saat pembayaran berhasil, dan expire saat transaksi kedaluwarsa. Aplikasi tidak boleh langsung menganggap transaksi sukses hanya karena SDK menutup. Verifikasi final tetap dilakukan server melalui webhook atau polling status, karena callback dari SDK hanya informasi awal.
Token pembayaran adalah tiket sekali pakai yang mengikat pesanan tertentu. Prinsip yang sama berlaku untuk Xendit maupun Midtrans: credential seperti server key tidak pernah muncul di kode aplikasi, karena aplikasi yang ter-reverse engineering akan membocorkan seluruh credential merchant.
Best Practices Produksi dan Validasi State Persistence
Aplikasi e-commerce yang siap produksi membutuhkan lebih dari sekadar alur yang berjalan. Network failure harus ditangani dengan retry dan feedback yang jelas. dio interceptor dapat mengimplementasikan retry otomatis untuk permintaan yang gagal karena timeout, sementara UI menampilkan state error yang informatif ketika pembayaran atau pengambilan produk gagal.
Keranjang belanja yang hilang saat aplikasi ditutup adalah pengalaman yang sangat buruk. Package hydrated_bloc menyelesaikan masalah ini dengan menyimpan state bloc secara otomatis ke local storage dan memuatnya kembali saat aplikasi dibuka. Cukup menambahkan HydratedBloc.storage saat inisialisasi, dan CartState akan persist tanpa kode tambahan.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}Testing merupakan investasi yang tidak bisa ditawar. Unit test untuk CartBloc memverifikasi setiap event menghasilkan state yang benar, sementara widget test memastikan alur checkout menampilkan step yang sesuai. Package bloc_test menyederhanakan penulisan test dengan helper blocTest yang menurunkan boilerplate secara signifikan.
blocTest<CartBloc, CartState>(
'menambahkan produk yang sama menaikkan quantity',
build: () => CartBloc(),
act: (bloc) async {
bloc.add(AddToCart(product: product));
bloc.add(AddToCart(product: product));
},
expect: () => [
CartState(items: [CartItem(product: product, quantity: 1)], totalItems: 1),
CartState(items: [CartItem(product: product, quantity: 2)], totalItems: 2),
],
);Kombinasi state immutable, logika terpusat di bloc, persistence dengan hydrated_bloc, dan test otomatis menghasilkan fondasi yang kokoh. Ketika fitur baru seperti voucher diskon atau loyalty points masuk, penambahan dilakukan di satu tempat saja tanpa menyentuh puluhan widget. Inilah return utama dari disiplin arsitektur pada aplikasi e-commerce.
Mau membangun aplikasi e-commerce Flutter yang scalable dan siap produksi? Bergabunglah dengan Mobile Development Bootcamp di Rumah Coding. Kurikulum praktis mencakup state management BLoC, arsitektur clean, dan integrasi payment gateway dengan proyek real-world.