Lewati ke isi

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

  1. Kenapa kita butuh “transaksi” terpisah dari “cart”?
  2. Kenapa data checkout (alamat, kurir, pembayaran) sebaiknya divalidasi sebelum lanjut?
  3. 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

  1. Tambahkan validasi no HP hanya angka (tidak boleh huruf).
  2. Tambahkan ringkasan item cart di halaman Transaction:
  3. nama produk, qty, subtotal.

Tugas Industri (Lebih Sulit)

  1. Tambahkan fitur voucher promo:
  2. input kode voucher (contoh: HEMAT10)
  3. diskon 10% dari itemsTotal (maksimal Rp 50.000)
  4. Tambahkan “konfirmasi checkout” dengan dialog:
  5. tampilkan grand total dan metode pembayaran.
  6. Simpan riwayat transaksi:
  7. buat halaman transaction_history_page.dart
  8. tampilkan list transaksi yang sudah pernah dibuat.

Refleksi

  1. Kenapa transaksi harus menyimpan snapshot item (bukan baca cart langsung)?
  2. Apa yang terjadi kalau cart tidak di-clear setelah transaksi?
  3. Bagian mana yang paling sering bug di fitur checkout (menurut kamu), dan kenapa?