Lewati ke isi

CRUD Product dengan Provider

Tujuan Pembelajaran

Setelah pertemuan ini, siswa mampu:
1. Membuat CRUD (Create, Read, Update, Delete) data Product menggunakan Provider (ChangeNotifier).
2. Menampilkan data product di halaman /data-product dalam bentuk tabel (DataTable).
3. Membuat tombol Add, serta aksi Edit dan Delete pada tabel.
4. Membuat 1 halaman form yang dipakai untuk Add dan Edit (satu form, dua mode).
5. Menerapkan pola clean code sederhana: pemisahan model, provider, pages, routes.


Pertanyaan Pemantik

  1. Mengapa data product sebaiknya tidak diubah langsung dari UI, tetapi lewat Provider?
  2. Kenapa form Add dan Edit sebaiknya 1 halaman saja (reusable), bukan bikin dua halaman terpisah?
  3. Apa risiko jika ID product tidak unik?
  4. Kalau nanti data product berasal dari API, bagian mana yang perlu diubah dan bagian mana yang bisa tetap sama?

Konsep Inti (Singkat tapi Bermakna)

1) Provider sebagai “Manajer Data”

  • UI hanya menampilkan dan memanggil fungsi.
  • Semua perubahan data terjadi di Provider, supaya perubahan terpusat dan rapi.

2) CRUD yang Benar

  • Create: tambah item baru ke list
  • Read: menampilkan list
  • Update: ubah item berdasarkan id
  • Delete: hapus item berdasarkan id
    Setiap operasi yang mengubah data wajib memanggil notifyListeners().

3) Satu Form untuk Add + Edit

  • Mode Add: form kosong, submit membuat product baru.
  • Mode Edit: form terisi data lama, submit mengubah product berdasarkan id.
    Kuncinya: halaman form menerima productId (opsional). Jika ada id berarti edit, kalau tidak ada berarti add.

Project dan Setup

Nama Project

Gunakan project yang sama dari pertemuan sebelumnya. Jika perlu membuat baru:
- Nama project: smk_product_app

Dependency (pastikan sudah ada)

Di pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.0.5
  go_router: ^14.0.0
Jalankan:
flutter pub get


Struktur Folder yang Dipakai

Buat/rapikan struktur berikut:

lib/
 ├── models/
 │   └── product.dart
 ├── data/
 │   └── dummy_products.dart
 ├── providers/
 │   └── product_crud_provider.dart
 ├── pages/
 │   ├── data_product_page.dart
 │   └── product_form_page.dart
 ├── routes/
 │   ├── app_routes.dart
 │   └── app_router.dart
 └── main.dart

Praktik Utama (Step-by-step)

Step 1 — Model Product

File: lib/models/product.dart

class Product {
  final String id;
  final String name;
  final String category;
  final String imageUrl;
  final int price;
  final String description;

  const Product({
    required this.id,
    required this.name,
    required this.category,
    required this.imageUrl,
    required this.price,
    required this.description,
  });

  Product copyWith({
    String? id,
    String? name,
    String? category,
    String? imageUrl,
    int? price,
    String? description,
  }) {
    return Product(
      id: id ?? this.id,
      name: name ?? this.name,
      category: category ?? this.category,
      imageUrl: imageUrl ?? this.imageUrl,
      price: price ?? this.price,
      description: description ?? this.description,
    );
  }
}

Catatan:
- copyWith membantu update data tanpa membuat ulang semua field manual.
- id wajib unik agar edit/delete akurat.


Step 2 — Dummy Data Product

File: lib/data/dummy_products.dart

import '../models/product.dart';

final List<Product> dummyProducts = [
  Product(
    id: 'p1',
    name: 'Monitor 24 inch',
    category: 'Elektronik',
    imageUrl: 'https://picsum.photos/seed/monitor/300/300',
    price: 1250000,
    description: 'Monitor 24 inch cocok untuk belajar dan kerja. Panel tajam dan nyaman dipakai lama.',
  ),
  Product(
    id: 'p2',
    name: 'Keyboard Mechanical',
    category: 'Aksesoris',
    imageUrl: 'https://picsum.photos/seed/keyboard/300/300',
    price: 450000,
    description: 'Keyboard mechanical dengan switch yang nyaman untuk mengetik dan gaming.',
  ),
  Product(
    id: 'p3',
    name: 'Mouse Wireless',
    category: 'Aksesoris',
    imageUrl: 'https://picsum.photos/seed/mouse/300/300',
    price: 180000,
    description: 'Mouse wireless ringan, baterai awet, cocok untuk mobilitas.',
  ),
];


Step 3 — Provider CRUD

File: lib/providers/product_crud_provider.dart

import 'package:flutter/material.dart';
import '../data/dummy_products.dart';
import '../models/product.dart';

class ProductCrudProvider extends ChangeNotifier {
  final List<Product> _products = List<Product>.from(dummyProducts);

  List<Product> get products => List.unmodifiable(_products);

  Product? getById(String id) {
    for (final p in _products) {
      if (p.id == id) return p;
    }
    return null;
  }

  // CREATE
  void addProduct(Product product) {
    // Pastikan id unik
    final exists = _products.any((p) => p.id == product.id);
    if (exists) {
      throw Exception('ID product sudah dipakai.');
    }
    _products.add(product);
    notifyListeners();
  }

  // UPDATE
  void updateProduct(Product updated) {
    final index = _products.indexWhere((p) => p.id == updated.id);
    if (index == -1) {
      throw Exception('Product tidak ditemukan.');
    }
    _products[index] = updated;
    notifyListeners();
  }

  // DELETE
  void deleteProduct(String id) {
    _products.removeWhere((p) => p.id == id);
    notifyListeners();
  }
}


Step 4 — Routing ke /data-product dan Form (1 halaman)

Kita pakai: - List: /data-product - Form: /data-product/form
Mode edit dikirim lewat query parameter: /data-product/form?id=p1

4A) Konstanta Route

File: lib/routes/app_routes.dart

class AppRoutes {
  static const dataProductName = 'dataProduct';
  static const dataProductPath = '/data-product';

  static const productFormName = 'productForm';
  static const productFormPath = 'form'; // subroute dari /data-product
}

4B) Router

File: lib/routes/app_router.dart

import 'package:go_router/go_router.dart';
import '../pages/data_product_page.dart';
import '../pages/product_form_page.dart';
import 'app_routes.dart';

GoRouter createRouter() {
  return GoRouter(
    initialLocation: AppRoutes.dataProductPath,
    routes: [
      GoRoute(
        name: AppRoutes.dataProductName,
        path: AppRoutes.dataProductPath,
        builder: (context, state) => const DataProductPage(),
        routes: [
          GoRoute(
            name: AppRoutes.productFormName,
            path: AppRoutes.productFormPath,
            builder: (context, state) {
              final id = state.uri.queryParameters['id'];
              return ProductFormPage(productId: id);
            },
          ),
        ],
      ),
    ],
  );
}


Step 5 — Daftarkan Provider di main.dart

File: lib/main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/product_crud_provider.dart';
import 'routes/app_router.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => ProductCrudProvider()),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final router = createRouter();

    return MaterialApp.router(
      debugShowCheckedModeBanner: false,
      title: 'SMK Product CRUD',
      routerConfig: router,
      theme: ThemeData(useMaterial3: true),
    );
  }
}


Halaman Utama /data-product (Tabel + Add/Edit/Delete)

Step 6 — DataProductPage (DataTable)

File: lib/pages/data_product_page.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import '../providers/product_crud_provider.dart';
import '../routes/app_routes.dart';

class DataProductPage extends StatelessWidget {
  const DataProductPage({super.key});

  Future<void> _confirmDelete(BuildContext context, String id) async {
    final ok = await showDialog<bool>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Hapus Product'),
        content: const Text('Yakin ingin menghapus product ini?'),
        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<ProductCrudProvider>().deleteProduct(id);
    }
  }

  @override
  Widget build(BuildContext context) {
    final provider = context.watch<ProductCrudProvider>();
    final items = provider.products;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Data Product'),
        actions: [
          IconButton(
            tooltip: 'Tambah Product',
            onPressed: () {
              // mode add: tanpa id
              context.push('${AppRoutes.dataProductPath}/${AppRoutes.productFormPath}');
            },
            icon: const Icon(Icons.add),
          ),
        ],
      ),
      body: items.isEmpty
          ? const Center(child: Text('Belum ada product.'))
          : SingleChildScrollView(
              padding: const EdgeInsets.all(16),
              scrollDirection: Axis.horizontal,
              child: DataTable(
                columns: const [
                  DataColumn(label: Text('ID')),
                  DataColumn(label: Text('Nama')),
                  DataColumn(label: Text('Kategori')),
                  DataColumn(label: Text('Harga')),
                  DataColumn(label: Text('Aksi')),
                ],
                rows: items.map((p) {
                  return DataRow(
                    cells: [
                      DataCell(Text(p.id)),
                      DataCell(Text(p.name)),
                      DataCell(Text(p.category)),
                      DataCell(Text(p.price.toString())),
                      DataCell(
                        Row(
                          children: [
                            IconButton(
                              tooltip: 'Edit',
                              onPressed: () {
                                // mode edit: pakai query id
                                context.push('${AppRoutes.dataProductPath}/${AppRoutes.productFormPath}?id=${p.id}');
                              },
                              icon: const Icon(Icons.edit_outlined),
                            ),
                            IconButton(
                              tooltip: 'Delete',
                              onPressed: () => _confirmDelete(context, p.id),
                              icon: const Icon(Icons.delete_outline),
                            ),
                          ],
                        ),
                      ),
                    ],
                  );
                }).toList(),
              ),
            ),
    );
  }
}

Catatan: - SingleChildScrollView horizontal berguna agar tabel tidak overflow di HP. - Delete memakai dialog konfirmasi supaya aman (mirip aplikasi nyata).


Form Add/Edit (Satu Halaman)

Step 7 — ProductFormPage (Add/Edit)

File: lib/pages/product_form_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../models/product.dart';
import '../providers/product_crud_provider.dart';

class ProductFormPage extends StatefulWidget {
  final String? productId; // null = add, ada id = edit

  const ProductFormPage({super.key, this.productId});

  @override
  State<ProductFormPage> createState() => _ProductFormPageState();
}

class _ProductFormPageState extends State<ProductFormPage> {
  final _formKey = GlobalKey<FormState>();

  final _idC = TextEditingController();
  final _nameC = TextEditingController();
  final _catC = TextEditingController();
  final _imgC = TextEditingController();
  final _priceC = TextEditingController();
  final _descC = TextEditingController();

  bool get isEdit => widget.productId != null;

  @override
  void initState() {
    super.initState();

    if (isEdit) {
      final provider = context.read<ProductCrudProvider>();
      final product = provider.getById(widget.productId!);

      if (product != null) {
        _idC.text = product.id;
        _nameC.text = product.name;
        _catC.text = product.category;
        _imgC.text = product.imageUrl;
        _priceC.text = product.price.toString();
        _descC.text = product.description;
      }
    }
  }

  @override
  void dispose() {
    _idC.dispose();
    _nameC.dispose();
    _catC.dispose();
    _imgC.dispose();
    _priceC.dispose();
    _descC.dispose();
    super.dispose();
  }

  void _save() {
    if (!(_formKey.currentState?.validate() ?? false)) return;

    final price = int.tryParse(_priceC.text.trim()) ?? 0;

    final product = Product(
      id: _idC.text.trim(),
      name: _nameC.text.trim(),
      category: _catC.text.trim(),
      imageUrl: _imgC.text.trim(),
      price: price,
      description: _descC.text.trim(),
    );

    final provider = context.read<ProductCrudProvider>();

    try {
      if (isEdit) {
        provider.updateProduct(product);
      } else {
        provider.addProduct(product);
      }

      Navigator.pop(context);
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Gagal: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final title = isEdit ? 'Edit Product' : 'Tambah Product';

    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Form(
            key: _formKey,
            child: Column(
              children: [
                TextFormField(
                  controller: _idC,
                  enabled: !isEdit, // kunci id saat edit
                  decoration: const InputDecoration(
                    labelText: 'ID',
                    border: OutlineInputBorder(),
                    hintText: 'contoh: p10',
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    if (s.isEmpty) return 'ID wajib diisi';
                    if (s.length < 2) return 'ID terlalu pendek';
                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: _nameC,
                  decoration: const InputDecoration(
                    labelText: 'Nama',
                    border: OutlineInputBorder(),
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    if (s.isEmpty) return 'Nama wajib diisi';
                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: _catC,
                  decoration: const InputDecoration(
                    labelText: 'Kategori',
                    border: OutlineInputBorder(),
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    if (s.isEmpty) return 'Kategori wajib diisi';
                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: _imgC,
                  decoration: const InputDecoration(
                    labelText: 'Image URL',
                    border: OutlineInputBorder(),
                    hintText: 'https://...',
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    if (s.isEmpty) return 'Image URL wajib diisi';
                    if (!s.startsWith('http')) return 'URL harus diawali http/https';
                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: _priceC,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(
                    labelText: 'Harga',
                    border: OutlineInputBorder(),
                    hintText: 'contoh: 150000',
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    final n = int.tryParse(s);
                    if (n == null) return 'Harga harus angka';
                    if (n <= 0) return 'Harga harus lebih dari 0';
                    return null;
                  },
                ),
                const SizedBox(height: 12),
                TextFormField(
                  controller: _descC,
                  minLines: 3,
                  maxLines: 6,
                  decoration: const InputDecoration(
                    labelText: 'Deskripsi',
                    border: OutlineInputBorder(),
                  ),
                  validator: (v) {
                    final s = (v ?? '').trim();
                    if (s.isEmpty) return 'Deskripsi wajib diisi';
                    if (s.length < 20) return 'Deskripsi minimal 20 karakter';
                    return null;
                  },
                ),
                const SizedBox(height: 16),
                SizedBox(
                  width: double.infinity,
                  height: 48,
                  child: ElevatedButton(
                    onPressed: _save,
                    child: Text(isEdit ? 'Simpan Perubahan' : 'Tambah Product'),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}


Mini Refleksi (untuk ditanya di kelas)

  1. Kenapa saat edit, field ID dikunci (enabled: false)?
  2. Kenapa products di provider dibuat List.unmodifiable?
  3. Apa yang terjadi jika kita lupa memanggil notifyListeners() setelah add/update/delete?
  4. Bagian mana yang perlu diubah jika data product diambil dari API?

Tugas (Tanpa Kode Jawaban)

Tugas Dasar

  1. Tambahkan kolom baru di tabel: Deskripsi (potong 20 karakter).
  2. Buat validasi tambahan: category hanya boleh salah satu dari: Elektronik, Aksesoris, Fashion (gunakan dropdown).

Tugas Industri (Lebih Sulit)

  1. Buat fitur Search di halaman /data-product untuk memfilter tabel berdasarkan nama/kategori.
  2. Tambahkan fitur Sort: harga termurah–termahal dan sebaliknya.
  3. Saat delete, tampilkan SnackBar Undo (hapus bisa dibatalkan).

Catatan untuk Guru

Jika nanti ingin disambungkan ke API: - List _products akan diganti hasil fetch dari API. - addProduct/updateProduct/deleteProduct akan memanggil API, lalu update list lokal. - UI DataTable dan Form tetap bisa dipakai hampir tanpa perubahan besar.