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
- Mengapa data product sebaiknya tidak diubah langsung dari UI, tetapi lewat Provider?
- Kenapa form Add dan Edit sebaiknya 1 halaman saja (reusable), bukan bikin dua halaman terpisah?
- Apa risiko jika ID product tidak unik?
- 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 memanggilnotifyListeners().
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 menerimaproductId(opsional). Jika adaidberarti 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
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)
- Kenapa saat edit, field
IDdikunci (enabled: false)? - Kenapa
productsdi provider dibuatList.unmodifiable? - Apa yang terjadi jika kita lupa memanggil
notifyListeners()setelah add/update/delete? - Bagian mana yang perlu diubah jika data product diambil dari API?
Tugas (Tanpa Kode Jawaban)
Tugas Dasar
- Tambahkan kolom baru di tabel: Deskripsi (potong 20 karakter).
- Buat validasi tambahan:
categoryhanya boleh salah satu dari:Elektronik,Aksesoris,Fashion(gunakan dropdown).
Tugas Industri (Lebih Sulit)
- Buat fitur Search di halaman
/data-productuntuk memfilter tabel berdasarkan nama/kategori. - Tambahkan fitur Sort: harga termurah–termahal dan sebaliknya.
- 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.