Checkout Transaction
Tujuan Pembelajaran
Siswa mampu:
1. Membuat halaman Transaction (checkout) berisi:
- Form pengiriman (nama penerima, no HP, alamat)
- Pilih kurir (dummy)
- Pilih metode pembayaran (dummy)
2. Membuat TransactionProvider untuk menyimpan transaksi.
3. Menghubungkan data Cart → masuk ke transaksi.
4. Menampilkan halaman Terima Kasih setelah transaksi dibuat, lalu arahkan ke Home.
Pertanyaan Pemantik
- Kenapa kita butuh “transaksi” terpisah dari “cart”?
- Kenapa data checkout (alamat, kurir, pembayaran) sebaiknya divalidasi sebelum lanjut?
- Kalau user menutup aplikasi setelah checkout, data transaksi sebaiknya hilang atau tersimpan?
Dependency yang Dibutuhkan
Pastikan di pubspec.yaml sudah ada:
dependencies:
flutter:
sdk: flutter
provider: ^6.0.5
go_router: ^14.0.0
Data Dummy Kurir & Pembayaran (Sudah Anda Berikan)
Kita akan pakai file dummy berikut:
- lib/data/dummy_kurir.dart
- lib/data/dummy_pembayaran.dart
Isi contoh datanya:
- Kurir: JNE, TIKI, POS Indonesia (ada biaya & estimasi)
- Pembayaran: Mandiri, BCA, Gopay, Ovo, Qris (ada gambarUrl)
Struktur Folder
Tambahkan folder/file berikut (atau sesuaikan jika sudah ada):
lib/
├── data/
│ ├── dummy_kurir.dart
│ └── dummy_pembayaran.dart
├── models/
│ ├── jenis_kurir.dart
│ ├── metode_pembayaran.dart
│ └── transaction_model.dart
├── providers/
│ └── transaction_provider.dart
├── pages/
│ ├── transaction_page.dart
│ └── transaction_success_page.dart
├── routes/
│ ├── app_routes.dart
│ └── app_router.dart
└── main.dart
Praktik Step-by-Step
Step 1 — Buat Model Kurir
Karena file dummy Anda meng-import models/jenis_kurir.dart, pastikan file ini ada.
File: lib/models/jenis_kurir.dart
class Jeniskurir {
final String id;
final String namaKurir;
final String estimasiWaktu;
final int biaya;
const Jeniskurir({
required this.id,
required this.namaKurir,
required this.estimasiWaktu,
required this.biaya,
});
}
Step 2 — Buat Model Metode Pembayaran
Karena file dummy Anda meng-import models/metode_pembayaran.dart, pastikan file ini ada.
File: lib/models/metode_pembayaran.dart
class Metodepembayaran {
final String id;
final String nama;
final String gambarUrl;
const Metodepembayaran({
required this.id,
required this.nama,
required this.gambarUrl,
});
}
Step 3 — Buat Model Transaction
Model transaksi menyimpan:
- Data penerima (nama/no hp/alamat)
- Kurir & pembayaran yang dipilih
- Item cart (snapshot saat checkout)
- Total harga
Catatan penting:
- Jangan hanya simpan “referensi” cart, tapi simpan snapshot agar transaksi tidak berubah saat cart berubah.
File: lib/models/transaction_model.dart
import 'jenis_kurir.dart';
import 'metode_pembayaran.dart';
class ShippingInfo {
final String receiverName;
final String phone;
final String address;
const ShippingInfo({
required this.receiverName,
required this.phone,
required this.address,
});
}
class TransactionItem {
final String productId;
final String name;
final int price;
final int qty;
const TransactionItem({
required this.productId,
required this.name,
required this.price,
required this.qty,
});
int get subtotal => price * qty;
}
class TransactionModel {
final String id;
final DateTime createdAt;
final ShippingInfo shipping;
final Jeniskurir courier;
final Metodepembayaran payment;
final List<TransactionItem> items;
final int itemsTotal;
final int courierFee;
final int grandTotal;
const TransactionModel({
required this.id,
required this.createdAt,
required this.shipping,
required this.courier,
required this.payment,
required this.items,
required this.itemsTotal,
required this.courierFee,
required this.grandTotal,
});
}
Step 4 — TransactionProvider (Simpan Transaksi)
Provider ini menyimpan list transaksi dan punya function createTransaction(...).
File: lib/providers/transaction_provider.dart
import 'package:flutter/material.dart';
import '../models/transaction_model.dart';
import '../models/jenis_kurir.dart';
import '../models/metode_pembayaran.dart';
class TransactionProvider extends ChangeNotifier {
final List<TransactionModel> _transactions = [];
List<TransactionModel> get transactions => List.unmodifiable(_transactions);
TransactionModel? _lastTransaction;
TransactionModel? get lastTransaction => _lastTransaction;
TransactionModel createTransaction({
required ShippingInfo shipping,
required Jeniskurir courier,
required Metodepembayaran payment,
required List<TransactionItem> items,
}) {
final itemsTotal = items.fold<int>(0, (sum, it) => sum + it.subtotal);
final courierFee = courier.biaya;
final grandTotal = itemsTotal + courierFee;
final trx = TransactionModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
createdAt: DateTime.now(),
shipping: shipping,
courier: courier,
payment: payment,
items: items,
itemsTotal: itemsTotal,
courierFee: courierFee,
grandTotal: grandTotal,
);
_transactions.insert(0, trx);
_lastTransaction = trx;
notifyListeners();
return trx;
}
void clearLastTransaction() {
_lastTransaction = null;
notifyListeners();
}
}
Step 5 — Daftarkan Provider di main.dart
Jika Anda sudah memakai MultiProvider, tinggal tambah 1 provider.
File: lib/main.dart (bagian providers)
ChangeNotifierProvider(create: (_) => TransactionProvider()),
Step 6 — Tambahkan Routes Transaction + Success
Contoh konstanta route:
File: lib/routes/app_routes.dart
class AppRoutes {
// ... yang sudah ada
static const transactionName = 'transaction';
static const transactionSuccessName = 'transactionSuccess';
static const transactionPath = '/transaction';
static const transactionSuccessPath = '/transaction/success';
// Home / Products (sesuaikan dengan project Anda)
// static const homeName = 'home';
// static const homePath = '/';
}
Tambahkan GoRoute-nya:
File: lib/routes/app_router.dart
Letakkan di
routes: []sesuai gaya router project Anda.
GoRoute(
name: AppRoutes.transactionName,
path: AppRoutes.transactionPath,
builder: (context, state) => const TransactionPage(),
),
GoRoute(
name: AppRoutes.transactionSuccessName,
path: AppRoutes.transactionSuccessPath,
builder: (context, state) => const TransactionSuccessPage(),
),
Halaman Transaction (Checkout)
Step 7 — TransactionPage (Form + Pilihan Kurir + Pembayaran)
Halaman ini:
- Validasi cart kosong (tidak boleh checkout kalau kosong)
- Form pengiriman
- Pilih kurir (radio / dropdown)
- Pilih metode pembayaran (list)
- Tombol bawah: Checkout / Simpan Transaksi
File: lib/pages/transaction_page.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../data/dummy_kurir.dart';
import '../data/dummy_pembayaran.dart';
import '../models/jenis_kurir.dart';
import '../models/metode_pembayaran.dart';
import '../models/transaction_model.dart';
import '../providers/transaction_provider.dart';
// Sesuaikan import CartProvider Anda
import '../providers/cart_provider.dart';
// Jika Anda punya ProductProvider / product map, sesuaikan
import '../providers/product_provider.dart';
import '../routes/app_routes.dart';
class TransactionPage extends StatefulWidget {
const TransactionPage({super.key});
@override
State<TransactionPage> createState() => _TransactionPageState();
}
class _TransactionPageState extends State<TransactionPage> {
final _formKey = GlobalKey<FormState>();
final _nameC = TextEditingController();
final _phoneC = TextEditingController();
final _addressC = TextEditingController();
Jeniskurir? _selectedCourier;
Metodepembayaran? _selectedPayment;
@override
void initState() {
super.initState();
_selectedCourier = dummyKurir.isNotEmpty ? dummyKurir.first : null;
_selectedPayment = dummyMetodePembayaran.isNotEmpty ? dummyMetodePembayaran.first : null;
}
@override
void dispose() {
_nameC.dispose();
_phoneC.dispose();
_addressC.dispose();
super.dispose();
}
Future<void> _checkout() async {
FocusScope.of(context).unfocus();
final cart = context.read<CartProvider>();
if (cart.items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Keranjang masih kosong. Tambahkan produk dulu.')),
);
return;
}
if (!(_formKey.currentState?.validate() ?? false)) return;
if (_selectedCourier == null || _selectedPayment == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pilih kurir dan metode pembayaran.')),
);
return;
}
// Ambil snapshot item cart → TransactionItem
// Catatan: Sesuaikan dengan struktur CartProvider Anda.
// Contoh asumsi:
// cart.items = Map<String, CartItem> (key: productId)
// CartItem: {productId, qty}
// ProductProvider: getById(productId)
final products = context.read<ProductProvider>();
final trxItems = <TransactionItem>[];
for (final entry in cart.items.entries) {
final productId = entry.key;
final cartItem = entry.value;
final p = products.getById(productId);
if (p == null) continue;
trxItems.add(TransactionItem(
productId: p.id,
name: p.name,
price: p.price,
qty: cartItem.qty,
));
}
if (trxItems.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produk tidak ditemukan. Pastikan ProductProvider tersedia.')),
);
return;
}
final shipping = ShippingInfo(
receiverName: _nameC.text.trim(),
phone: _phoneC.text.trim(),
address: _addressC.text.trim(),
);
context.read<TransactionProvider>().createTransaction(
shipping: shipping,
courier: _selectedCourier!,
payment: _selectedPayment!,
items: trxItems,
);
// Setelah transaksi dibuat, cart harus dikosongkan
cart.clear();
if (!mounted) return;
context.goNamed(AppRoutes.transactionSuccessName);
}
@override
Widget build(BuildContext context) {
final cart = context.watch<CartProvider>();
final cs = Theme.of(context).colorScheme;
final itemsTotal = cart.totalPrice; // pastikan CartProvider punya totalPrice
return Scaffold(
appBar: AppBar(title: const Text('Transaction')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Banner ringkas
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: cs.primaryContainer,
),
child: Text(
'Lengkapi data pengiriman, pilih kurir, lalu pilih metode pembayaran.',
style: TextStyle(fontWeight: FontWeight.w700, color: cs.onPrimaryContainer),
),
),
const SizedBox(height: 14),
// Validasi cart kosong (UI)
if (cart.items.isEmpty) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.red.withOpacity(0.08),
),
child: const Text(
'Keranjang kosong. Silakan tambah produk dulu sebelum transaction.',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.w800),
),
),
const SizedBox(height: 14),
],
// FORM PENGIRIMAN
Text('Form Pengiriman', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 16, color: cs.onSurface)),
const SizedBox(height: 10),
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameC,
decoration: const InputDecoration(
labelText: 'Nama Penerima',
border: OutlineInputBorder(),
),
validator: (v) {
final s = (v ?? '').trim();
if (s.length < 3) return 'Nama minimal 3 karakter';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneC,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'No HP',
border: OutlineInputBorder(),
),
validator: (v) {
final s = (v ?? '').trim();
if (s.isEmpty) return 'No HP wajib diisi';
if (s.length < 10) return 'No HP minimal 10 digit';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _addressC,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Alamat Lengkap',
border: OutlineInputBorder(),
),
validator: (v) {
final s = (v ?? '').trim();
if (s.length < 10) return 'Alamat terlalu pendek';
return null;
},
),
],
),
),
const SizedBox(height: 18),
// PILIH KURIR
Text('Pilih Kurir', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 16, color: cs.onSurface)),
const SizedBox(height: 10),
...dummyKurir.map((k) {
final selected = _selectedCourier?.id == k.id;
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? cs.primary : cs.outlineVariant),
),
child: RadioListTile<Jeniskurir>(
value: k,
groupValue: _selectedCourier,
onChanged: (v) => setState(() => _selectedCourier = v),
title: Text(k.namaKurir, style: const TextStyle(fontWeight: FontWeight.w800)),
subtitle: Text('Estimasi: ${k.estimasiWaktu} • Biaya: Rp ${k.biaya}'),
),
);
}),
const SizedBox(height: 8),
// PILIH METODE PEMBAYARAN
Text('Metode Pembayaran', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 16, color: cs.onSurface)),
const SizedBox(height: 10),
...dummyMetodePembayaran.map((m) {
final selected = _selectedPayment?.id == m.id;
return InkWell(
onTap: () => setState(() => _selectedPayment = m),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: selected ? cs.primary : cs.outlineVariant),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: cs.surfaceContainerHighest,
),
clipBehavior: Clip.antiAlias,
child: Image.network(
m.gambarUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.image_not_supported),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(m.nama, style: const TextStyle(fontWeight: FontWeight.w800)),
),
Icon(selected ? Icons.check_circle : Icons.circle_outlined, color: selected ? cs.primary : cs.outlineVariant),
],
),
),
);
}),
const SizedBox(height: 80), // jarak untuk bottom bar
],
),
),
// BOTTOM CHECKOUT BAR
bottomNavigationBar: SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: cs.outlineVariant)),
color: cs.surface,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('Total Cart: ', style: TextStyle(fontWeight: FontWeight.w800)),
const Spacer(),
Text('Rp $itemsTotal', style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 16)),
],
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: cart.items.isEmpty ? null : _checkout,
child: const Text('Checkout / Simpan Transaksi'),
),
),
],
),
),
),
);
}
}
Catatan penyesuaian wajib (karena project Anda sudah punya CartProvider)
Agar kode di atas jalan, pastikan CartProvider punya:
- Map<String, CartItem> get items
- int get totalPrice
- void clear()
Jika nama method/field beda, sesuaikan saja.
Halaman Terima Kasih (Success)
Step 8 — TransactionSuccessPage (UI Terima Kasih + Kembali ke Home)
Halaman ini membaca transaksi terakhir dari TransactionProvider.
File: lib/pages/transaction_success_page.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../providers/transaction_provider.dart';
import '../routes/app_routes.dart';
class TransactionSuccessPage extends StatelessWidget {
const TransactionSuccessPage({super.key});
@override
Widget build(BuildContext context) {
final trxProvider = context.watch<TransactionProvider>();
final trx = trxProvider.lastTransaction;
final cs = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Padding(
padding: const EdgeInsets.all(20),
child: trx == null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.info_outline, size: 56),
const SizedBox(height: 10),
const Text('Tidak ada transaksi terakhir.', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 18)),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
// Sesuaikan nama route Home Anda
// context.goNamed(AppRoutes.homeName);
context.go('/'); // fallback jika home adalah "/"
},
child: const Text('Kembali ke Home'),
),
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: cs.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Icon(Icons.check_circle, size: 56, color: cs.primary),
),
const SizedBox(height: 16),
const Text(
'Terima kasih!',
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 24),
),
const SizedBox(height: 6),
Text(
'Transaksi kamu berhasil dibuat.',
style: TextStyle(color: cs.onSurfaceVariant, fontWeight: FontWeight.w700),
),
const SizedBox(height: 18),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: cs.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ringkasan', style: TextStyle(fontWeight: FontWeight.w900, color: cs.onSurface)),
const SizedBox(height: 10),
Text('Penerima: ${trx.shipping.receiverName}'),
Text('No HP: ${trx.shipping.phone}'),
Text('Alamat: ${trx.shipping.address}'),
const Divider(height: 22),
Text('Kurir: ${trx.courier.namaKurir} (${trx.courier.estimasiWaktu})'),
Text('Pembayaran: ${trx.payment.nama}'),
const Divider(height: 22),
Row(
children: [
const Text('Grand Total', style: TextStyle(fontWeight: FontWeight.w900)),
const Spacer(),
Text('Rp ${trx.grandTotal}', style: const TextStyle(fontWeight: FontWeight.w900)),
],
)
],
),
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () {
trxProvider.clearLastTransaction();
// Sesuaikan route home Anda
// context.goNamed(AppRoutes.homeName);
context.go('/'); // fallback jika home adalah "/"
},
child: const Text('Kembali ke Home'),
),
),
],
),
),
),
),
),
);
}
}
Checklist Keberhasilan
- Transaction page menolak checkout jika cart kosong.
- Form pengiriman wajib valid sebelum checkout.
- Kurir & pembayaran bisa dipilih dari data dummy.
- Saat checkout:
- transaksi tersimpan di
TransactionProvider - cart di-clear
- pindah ke halaman terima kasih
- Tombol “Kembali ke Home” mengarahkan user ke halaman utama.
Tugas (Tanpa Kode Jawaban)
Tugas Dasar
- Tambahkan validasi no HP hanya angka (tidak boleh huruf).
- Tambahkan ringkasan item cart di halaman Transaction:
- nama produk, qty, subtotal.
Tugas Industri (Lebih Sulit)
- Tambahkan fitur voucher promo:
- input kode voucher (contoh:
HEMAT10) - diskon 10% dari itemsTotal (maksimal Rp 50.000)
- Tambahkan “konfirmasi checkout” dengan dialog:
- tampilkan grand total dan metode pembayaran.
- Simpan riwayat transaksi:
- buat halaman
transaction_history_page.dart - tampilkan list transaksi yang sudah pernah dibuat.
Refleksi
- Kenapa transaksi harus menyimpan snapshot item (bukan baca cart langsung)?
- Apa yang terjadi kalau cart tidak di-clear setelah transaksi?
- Bagian mana yang paling sering bug di fitur checkout (menurut kamu), dan kenapa?