
State Management: setState() dan Provider pada Aplikasi ResepKu
Penerapan state lokal dengan setState() untuk fitur jumlah porsi dan state global dengan Provider untuk fitur favorit yang dipakai bersama oleh daftar resep, badge, halaman detail, dan halaman Favorit pada aplikasi ResepKu, dilengkapi tema gelap/terang dan fitur hapus semua favorit.
ITujuan Praktikum
Setelah menyelesaikan praktikum ini, mahasiswa diharapkan mampu:
- ✓Membuat state lokal dengan StatefulWidget dan setState() pada fitur jumlah porsi di halaman detail.
- ✓Menjelaskan keterbatasan state lokal ketika satu data dibutuhkan oleh banyak widget dan halaman.
- ✓Membuat state global dengan package provider (ChangeNotifier dan MultiProvider) pada fitur favorit.
- ✓Membaca state dengan context.watch, context.read, dan Consumer di beberapa halaman.
- ✓Menerapkan provider tambahan untuk tema gelap/terang dan fitur hapus semua favorit dengan dialog konfirmasi.
IIDasar Teori
State: Data yang dapat berubah selama aplikasi berjalan dan memengaruhi tampilan. Di Flutter, tampilan adalah fungsi dari state (UI = f(state)): ketika state berubah, widget dibangun ulang (rebuild) agar tampilan sesuai dengan data terbaru.
State lokal (ephemeral) dan setState(): State yang hanya dibutuhkan oleh satu widget disimpan sebagai field di class State milik sebuah StatefulWidget. Nilai awal diisi di initState(), data milik widget diakses melalui widget.namaField, dan setiap perubahan dibungkus setState(() { ... }) agar Flutter menjalankan ulang build(). State ini ikut dibuang (dispose) ketika widget-nya dikeluarkan dari widget tree, misalnya saat halaman ditutup.
App state dan Provider: State yang dibutuhkan banyak widget dan halaman sekaligus disimpan di satu tempat di atas widget-widget tersebut. Package provider menyediakan cara untuk menaruh objek state di widget tree sehingga bisa dibaca oleh semua widget di bawahnya.
ChangeNotifier: class yang menyimpan daftar listener dan memberi tahu mereka lewatnotifyListeners()setiap kali data berubah.ChangeNotifierProvider: widget yang membuat dan menyediakan objekChangeNotifieruntuk widget di bawahnya.MultiProvidermenggabungkan beberapa provider agar mudah ditambah.context.watch<T>(): dipakai di dalambuild(). Widget didaftarkan sebagai listener sehingga ikut rebuild ketika data berubah.context.read<T>(): dipakai di dalam callback sepertionPressed. Hanya mengambil objek untuk memanggil method, tanpa mendengarkan perubahan.Consumer<T>: bentuk widget daricontext.watch. Fungsibuilder-nya dijalankan ulang setiap kalinotifyListeners()dipanggil, sehingga rebuild hanya terjadi pada bagian yang dibungkus.
Posisi provider di widget tree: Provider dicari dengan menelusuri ancestor (widget di atasnya). Karena Navigator.push menaruh halaman baru sebagai anak Navigator yang dibuat oleh MaterialApp, provider yang dibutuhkan banyak halaman harus dipasang di atas MaterialApp. Bila provider tidak ditemukan, muncul ProviderNotFoundException.
Hot reload dan hot restart: Hot reload hanya menjalankan ulang build() dan mempertahankan state, sedangkan hot restart menjalankan ulang main() dari awal. Setiap perubahan pada main(), termasuk menambah provider, membutuhkan hot restart.
IIIAlat dan Bahan
IVLangkah Kerja Praktikum
Bagian A: Menyiapkan Project ResepKu
Project Flutter dibuat dengan nama package flutter_application_1, sehingga seluruh import memakai package:flutter_application_1/... (modul memakai package:resepku/...). Di dalam folder lib/ dibuat lima folder: models, data, providers, pages, dan widgets. File test/widget_test.dart bawaan template dihapus karena merujuk class MyApp yang tidak ada lagi.
lib/
├── main.dart
├── models/
│ └── recipe.dart
├── data/
│ └── dummy_recipes.dart
├── providers/
│ ├── favorite_provider.dart
│ └── theme_provider.dart # Latihan 1
├── pages/
│ ├── home_page.dart
│ ├── detail_page.dart
│ ├── favorite_page.dart
│ └── settings_page.dart # Latihan 1
└── widgets/
├── identity_footer.dart
├── recipe_tile.dart
└── favorite_badge_button.dart1.1 — Model Recipe
Class Recipe merepresentasikan satu resep. Field image belum dipakai dan baru digunakan pada Modul 7. Getter totalTimeMinutes menghitung total waktu memasak dan dipanggil tanpa tanda kurung seperti field biasa.
class Recipe {
final int id;
final String name;
final String cuisine;
final String difficulty;
final int prepTimeMinutes;
final int cookTimeMinutes;
final int servings;
final int caloriesPerServing;
final double rating;
final String image; // dipakai mulai Modul 7
final List<String> ingredients;
const Recipe({
required this.id,
required this.name,
required this.cuisine,
required this.difficulty,
required this.prepTimeMinutes,
required this.cookTimeMinutes,
required this.servings,
required this.caloriesPerServing,
required this.rating,
required this.image,
required this.ingredients,
});
int get totalTimeMinutes => prepTimeMinutes + cookTimeMinutes;
}1.2 — Data Dummy
Enam resep pertama dari dummyjson.com/recipes disimpan sebagai list konstan. Pada Modul 8, sumber data cukup diganti ke API tanpa mengubah UI.
import 'package:flutter_application_1/models/recipe.dart';
// Data sementara. Isinya sama dengan https://dummyjson.com/recipes (id 1-6),
// sehingga di Modul 8 cukup sumber datanya yang diganti ke API.
const List<Recipe> dummyRecipes = [
Recipe(
id: 1,
name: 'Classic Margherita Pizza',
cuisine: 'Italian',
difficulty: 'Easy',
prepTimeMinutes: 20,
cookTimeMinutes: 15,
servings: 4,
caloriesPerServing: 300,
rating: 4.6,
image: 'https://cdn.dummyjson.com/recipe-images/1.webp',
ingredients: [
'Pizza dough',
'Tomato sauce',
'Fresh mozzarella cheese',
'Fresh basil leaves',
'Olive oil',
'Salt and pepper to taste',
],
),
Recipe(
id: 2,
name: 'Vegetarian Stir-Fry',
cuisine: 'Asian',
difficulty: 'Medium',
prepTimeMinutes: 15,
cookTimeMinutes: 20,
servings: 3,
caloriesPerServing: 250,
rating: 4.7,
image: 'https://cdn.dummyjson.com/recipe-images/2.webp',
ingredients: [
'Tofu, cubed',
'Broccoli florets',
'Carrots, sliced',
'Bell peppers, sliced',
'Soy sauce',
'Ginger, minced',
'Garlic, minced',
'Sesame oil',
'Cooked rice for serving',
],
),
Recipe(
id: 3,
name: 'Chocolate Chip Cookies',
cuisine: 'American',
difficulty: 'Easy',
prepTimeMinutes: 15,
cookTimeMinutes: 10,
servings: 24,
caloriesPerServing: 150,
rating: 4.9,
image: 'https://cdn.dummyjson.com/recipe-images/3.webp',
ingredients: [
'All-purpose flour',
'Butter, softened',
'Brown sugar',
'White sugar',
'Eggs',
'Vanilla extract',
'Baking soda',
'Salt',
'Chocolate chips',
],
),
Recipe(
id: 4,
name: 'Chicken Alfredo Pasta',
cuisine: 'Italian',
difficulty: 'Medium',
prepTimeMinutes: 15,
cookTimeMinutes: 20,
servings: 4,
caloriesPerServing: 500,
rating: 4.9,
image: 'https://cdn.dummyjson.com/recipe-images/4.webp',
ingredients: [
'Fettuccine pasta',
'Chicken breast, sliced',
'Heavy cream',
'Parmesan cheese, grated',
'Garlic, minced',
'Butter',
'Salt and pepper to taste',
'Fresh parsley for garnish',
],
),
Recipe(
id: 5,
name: 'Mango Salsa Chicken',
cuisine: 'Mexican',
difficulty: 'Easy',
prepTimeMinutes: 15,
cookTimeMinutes: 25,
servings: 3,
caloriesPerServing: 380,
rating: 4.9,
image: 'https://cdn.dummyjson.com/recipe-images/5.webp',
ingredients: [
'Chicken thighs',
'Mango, diced',
'Red onion, finely chopped',
'Cilantro, chopped',
'Lime juice',
'Jalapeño, minced',
'Salt and pepper to taste',
'Cooked rice for serving',
],
),
Recipe(
id: 6,
name: 'Quinoa Salad with Avocado',
cuisine: 'Mediterranean',
difficulty: 'Easy',
prepTimeMinutes: 20,
cookTimeMinutes: 15,
servings: 4,
caloriesPerServing: 280,
rating: 4.4,
image: 'https://cdn.dummyjson.com/recipe-images/6.webp',
ingredients: [
'Quinoa, cooked',
'Avocado, diced',
'Cherry tomatoes, halved',
'Cucumber, diced',
'Red bell pepper, diced',
'Feta cheese, crumbled',
'Lemon vinaigrette dressing',
'Salt and pepper to taste',
],
),
];1.3 — Footer Identitas
IdentityFooter menampilkan NIM dan nama di bawah setiap halaman. Widget ini dipasang sekali melalui parameter builder pada MaterialApp, sehingga membungkus Navigator dan tampil di semua route tanpa perlu ditambahkan ke setiap Scaffold.
import 'package:flutter/material.dart';
// GANTI dengan NIM dan nama lengkap Kalian sendiri
const String kNim = '2411532011';
const String kNama = 'Aufan Taufiqurrahman';
/// Menampilkan NIM dan nama di bawah setiap halaman.
/// Dipasang sekali di MaterialApp.builder, jadi berlaku untuk semua route.
class IdentityFooter extends StatelessWidget {
const IdentityFooter({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
// ruang aman bawah sudah diurus footer, jadi tidak dihitung dua kali
child: MediaQuery.removePadding(
context: context,
removeBottom: true,
child: child,
),
),
Material(
color: Colors.black87,
child: SafeArea(
top: false,
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
'$kNim • $kNama',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
),
),
],
);
}
}1.4 — main.dart, HomePage, dan RecipeTile (versi awal)
Versi awal belum memiliki fitur favorit. HomePage menampilkan daftar resep dengan ListView, dan setiap resep ditampilkan oleh RecipeTile yang membuka DetailPage saat diketuk.
import 'package:flutter/material.dart';
import 'package:flutter_application_1/pages/home_page.dart';
import 'package:flutter_application_1/widgets/identity_footer.dart';
void main() {
runApp(const ResepKuApp());
}
class ResepKuApp extends StatelessWidget {
const ResepKuApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ResepKu',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.deepOrange),
builder: (context, child) => IdentityFooter(child: child!), // footer NIM & nama
home: const HomePage(),
);
}
}import 'package:flutter/material.dart';
import 'package:flutter_application_1/data/dummy_recipes.dart';
import 'package:flutter_application_1/widgets/recipe_tile.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ResepKu'),
),
body: ListView(
children: [
for (final recipe in dummyRecipes) RecipeTile(recipe: recipe),
],
),
);
}
}import 'package:flutter/material.dart';
import 'package:flutter_application_1/models/recipe.dart';
import 'package:flutter_application_1/pages/detail_page.dart';
class RecipeTile extends StatelessWidget {
const RecipeTile({super.key, required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
return ListTile(
leading: CircleAvatar(child: Text(recipe.name[0])),
title: Text(recipe.name),
subtitle: Text(
'${recipe.cuisine} • ${recipe.totalTimeMinutes} menit • ★ ${recipe.rating}',
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailPage(recipe: recipe)),
),
);
}
}Bagian A: Halaman Detail dengan setState()
DetailPage dibuat sebagai StatefulWidget karena jumlah porsi berubah ketika tombol + atau − ditekan. Nilai _servings diisi dari widget.recipe.servings di initState(), lalu diubah oleh _addServing() dan _removeServing() di dalam setState(). Setiap rebuild menghitung ulang totalCalories = caloriesPerServing × _servings.
import 'package:flutter/material.dart';
import 'package:flutter_application_1/models/recipe.dart';
class DetailPage extends StatefulWidget {
const DetailPage({super.key, required this.recipe});
final Recipe recipe;
@override
State<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage> {
late int _servings; // state lokal: hanya halaman ini yang butuh
@override
void initState() {
super.initState();
_servings = widget.recipe.servings; // nilai awal dari data resep
}
void _addServing() {
setState(() => _servings++);
}
void _removeServing() {
if (_servings > 1) {
setState(() => _servings--);
}
}
@override
Widget build(BuildContext context) {
final recipe = widget.recipe;
final totalCalories = recipe.caloriesPerServing * _servings;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: Text(recipe.name)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'${recipe.cuisine} • ${recipe.difficulty} • ${recipe.totalTimeMinutes} menit',
style: textTheme.bodyLarge,
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Text('Porsi', style: textTheme.titleMedium),
const Spacer(),
IconButton.filledTonal(
onPressed: _servings > 1 ? _removeServing : null,
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('$_servings', style: textTheme.titleLarge),
),
IconButton.filledTonal(
onPressed: _addServing,
icon: const Icon(Icons.add),
),
],
),
),
),
const SizedBox(height: 8),
Text(
'Total kalori: $totalCalories kkal',
style: textTheme.titleMedium,
),
const SizedBox(height: 24),
Text('Bahan', style: textTheme.titleLarge),
for (final item in recipe.ingredients)
ListTile(
dense: true,
leading: const Icon(Icons.check_circle_outline),
title: Text(item),
),
],
),
);
}
}2.1 — Checkpoint 1
Classic Margherita Pizza memiliki 300 kkal per porsi. Pada porsi 6, total kalori yang tampil adalah 1800 kkal (6 × 300). Pada porsi 1, tombol − berwarna abu-abu dan tidak dapat ditekan.
Tombol − dinonaktifkan oleh baris 63 pada detail_page.dart. Ekspresi ternary _servings > 1 ? _removeServing : null memberikan fungsi _removeServing ketika porsi lebih dari 1, dan null ketika porsi bernilai 1. IconButton yang menerima onPressed: null otomatis ditampilkan dalam keadaan nonaktif (disabled). Karena ekspresi ini dievaluasi ulang setiap build(), tombol langsung aktif kembali begitu porsi dinaikkan menjadi 2. Sebagai lapisan pengaman kedua, _removeServing() juga memeriksa if (_servings > 1) sebelum mengurangi nilai.
IconButton.filledTonal(
onPressed: _servings > 1 ? _removeServing : null, // baris 63
icon: const Icon(Icons.remove),
),Bagian B: Eksperimen Favorit dengan State Lokal
Sebelum memakai Provider, RecipeTile diubah menjadi StatefulWidget dan setiap tile menyimpan status favoritnya sendiri di field _isFavorite. Setelah hot reload, ikon hati pada dua resep ditekan untuk mengamati hasilnya.
import 'package:flutter/material.dart';
import 'package:flutter_application_1/models/recipe.dart';
import 'package:flutter_application_1/pages/detail_page.dart';
// VERSI EKSPERIMEN: favorit disimpan sebagai state lokal di setiap tile
class RecipeTile extends StatefulWidget {
const RecipeTile({super.key, required this.recipe});
final Recipe recipe;
@override
State<RecipeTile> createState() => _RecipeTileState();
}
class _RecipeTileState extends State<RecipeTile> {
bool _isFavorite = false;
@override
Widget build(BuildContext context) {
final recipe = widget.recipe;
return ListTile(
leading: CircleAvatar(child: Text(recipe.name[0])),
title: Text(recipe.name),
subtitle: Text(
'${recipe.cuisine} • ${recipe.totalTimeMinutes} menit • ★ ${recipe.rating}',
),
trailing: IconButton(
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite ? Colors.red : null,
),
onPressed: () => setState(() => _isFavorite = !_isFavorite),
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailPage(recipe: recipe)),
),
);
}
}3.1 — Hasil Pengamatan
Apakah ikon hati berubah? Mengapa? Ya, ikon hati pada tile yang ditekan berubah dari garis (
favorite_border) menjadi hati merah terisi (favorite).onPressedmemanggilsetState(() => _isFavorite = !_isFavorite)di dalam_RecipeTileState, sehingga tile tersebut dibangun ulang dengan nilai_isFavoriteyang baru. Hanya tile yang ditekan yang berubah, karena setiap tile memiliki objek State sendiri.Bisakah jumlah favorit ditampilkan di judul AppBar HomePage? Tidak bisa. Data
_isFavoriteberada di class_RecipeTileState, bersifat private (diawali garis bawah), dan tersebar menjadi enam salinan terpisah di enam tile. Data di Flutter mengalir ke bawah melalui konstruktor;HomePagesebagai induk hanya membuatRecipeTile(recipe: recipe)dan tidak memegang referensi ke State milik tile, sehingga tidak dapat membaca atau menjumlahkan status favorit tersebut.Bagaimana halaman detail dapat mengetahui bahwa resep tersebut favorit? Halaman detail tidak dapat mengetahuinya.
DetailPagehanya menerima objekRecipemelalui konstruktor, dan modelRecipetidak memiliki field favorit. Status favorit tetap tinggal di_RecipeTileStatepada halaman Home, yang merupakan route berbeda dan tidak terhubung denganDetailPage.
Kesimpulannya, data favorit dibutuhkan di banyak tempat sekaligus (daftar resep, badge, halaman detail, dan halaman Favorit). Jika disimpan di setiap tile, data tersebut tidak dapat dibaca oleh widget lain, sehingga perlu dipindahkan ke atas ke dalam sebuah Provider.
Bagian C: Memasang Provider dan Membuat FavoriteProvider
Package provider ditambahkan ke pubspec.yaml melalui terminal. Setelah itu dibuat class FavoriteProvider yang meng-extend ChangeNotifier. List _items bersifat private dan dari luar hanya bisa dibaca lewat getter items yang unmodifiable, sehingga setiap perubahan wajib melalui toggle() atau clear() yang memanggil notifyListeners(). Resep dibandingkan berdasarkan id, bukan objeknya, karena pada Modul 8 objek Recipe akan dibuat ulang dari JSON.
flutter pub add providerimport 'package:flutter/foundation.dart';
import 'package:flutter_application_1/models/recipe.dart';
class FavoriteProvider extends ChangeNotifier {
final List<Recipe> _items = [];
// Dari luar hanya boleh dibaca, tidak boleh diubah langsung
List<Recipe> get items => List.unmodifiable(_items);
int get count => _items.length;
bool isFavorite(Recipe recipe) => _items.any((r) => r.id == recipe.id);
void toggle(Recipe recipe) {
if (isFavorite(recipe)) {
_items.removeWhere((r) => r.id == recipe.id);
} else {
_items.add(recipe);
}
notifyListeners(); // beri tahu semua widget yang "mendengarkan"
}
void clear() {
_items.clear();
notifyListeners();
}
}4.1 — Memasang Provider di atas MaterialApp
ChangeNotifierProvider dipasang di dalam MultiProvider di atas MaterialApp, agar semua halaman (route) dapat mengakses FavoriteProvider. Baris builder untuk footer identitas tetap dipertahankan. Setelah main.dart diubah, aplikasi wajib di-hot restart karena hot reload tidak menjalankan ulang main().
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/pages/home_page.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
import 'package:flutter_application_1/widgets/identity_footer.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => FavoriteProvider()),
],
child: const ResepKuApp(),
),
);
}
class ResepKuApp extends StatelessWidget {
const ResepKuApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ResepKu',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.deepOrange),
builder: (context, child) =>
IdentityFooter(child: child!), // tetap dipertahankan
home: const HomePage(),
);
}
}Bagian C: Membaca Provider di Beberapa Halaman
Data favorit kini dibaca dari satu sumber oleh beberapa widget. Aturannya: context.watch di dalam build() agar widget ikut rebuild ketika data berubah, dan context.read di dalam callback untuk memanggil method tanpa mendengarkan perubahan.
5.1 — RecipeTile
RecipeTile kembali menjadi StatelessWidget. Status favorit dibaca dengan context.watch<FavoriteProvider>().isFavorite(recipe), sedangkan ikon hati memanggil context.read<FavoriteProvider>().toggle(recipe).
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/models/recipe.dart';
import 'package:flutter_application_1/pages/detail_page.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
class RecipeTile extends StatelessWidget {
const RecipeTile({super.key, required this.recipe});
final Recipe recipe;
@override
Widget build(BuildContext context) {
// watch: widget ini ikut rebuild saat daftar favorit berubah
final isFavorite = context.watch<FavoriteProvider>().isFavorite(recipe);
return ListTile(
leading: CircleAvatar(child: Text(recipe.name[0])),
title: Text(recipe.name),
subtitle: Text(
'${recipe.cuisine} • ${recipe.totalTimeMinutes} menit • ★ ${recipe.rating}',
),
trailing: IconButton(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : null,
),
// read: di dalam callback cukup "membaca", tidak perlu listen
onPressed: () => context.read<FavoriteProvider>().toggle(recipe),
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => DetailPage(recipe: recipe)),
),
);
}
}5.2 — Tombol Favorit di Halaman Detail
Halaman detail ditambah dua import, variabel isFavorite setelah baris totalCalories, dan parameter actions pada AppBar. Halaman ini sekarang memakai dua jenis state sekaligus: _servings sebagai state lokal (setState) dan favorit sebagai state global (Provider).
import 'package:provider/provider.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
// di dalam build(), tepat setelah baris totalCalories
final isFavorite = context.watch<FavoriteProvider>().isFavorite(recipe);
// parameter actions pada AppBar
actions: [
IconButton(
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
onPressed: () => context.read<FavoriteProvider>().toggle(recipe),
),
],import 'package:flutter/material.dart';
import 'package:flutter_application_1/models/recipe.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
class DetailPage extends StatefulWidget {
const DetailPage({super.key, required this.recipe});
final Recipe recipe;
@override
State<DetailPage> createState() => _DetailPageState();
}
class _DetailPageState extends State<DetailPage> {
late int _servings; // state lokal: hanya halaman ini yang butuh
@override
void initState() {
super.initState();
_servings = widget.recipe.servings; // nilai awal dari data resep
}
void _addServing() {
setState(() => _servings++);
}
void _removeServing() {
if (_servings > 1) {
setState(() => _servings--);
}
}
@override
Widget build(BuildContext context) {
final recipe = widget.recipe;
final totalCalories = recipe.caloriesPerServing * _servings;
final isFavorite = context.watch<FavoriteProvider>().isFavorite(recipe);
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: Text(recipe.name),
actions: [
IconButton(
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
onPressed: () => context.read<FavoriteProvider>().toggle(recipe),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'${recipe.cuisine} • ${recipe.difficulty} • ${recipe.totalTimeMinutes} menit',
style: textTheme.bodyLarge,
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Text('Porsi', style: textTheme.titleMedium),
const Spacer(),
IconButton.filledTonal(
onPressed: _servings > 1 ? _removeServing : null,
icon: const Icon(Icons.remove),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('$_servings', style: textTheme.titleLarge),
),
IconButton.filledTonal(
onPressed: _addServing,
icon: const Icon(Icons.add),
),
],
),
),
),
const SizedBox(height: 8),
Text(
'Total kalori: $totalCalories kkal',
style: textTheme.titleMedium,
),
const SizedBox(height: 24),
Text('Bahan', style: textTheme.titleLarge),
for (final item in recipe.ingredients)
ListTile(
dense: true,
leading: const Icon(Icons.check_circle_outline),
title: Text(item),
),
],
),
);
}
}5.3 — Halaman Favorit
Halaman Favorit memakai Consumer<FavoriteProvider>. Bila daftar kosong, halaman menampilkan pesan "Belum ada resep favorit"; bila ada, setiap resep ditampilkan dengan tombol hapus yang memanggil toggle().
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
class FavoritePage extends StatelessWidget {
const FavoritePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Favorit Saya')),
body: Consumer<FavoriteProvider>(
builder: (context, favorite, child) {
if (favorite.count == 0) {
return const Center(
child: Text(
'Belum ada resep favorit.\nTekan ikon hati di daftar resep.',
textAlign: TextAlign.center,
),
);
}
return ListView(
children: [
for (final recipe in favorite.items)
ListTile(
title: Text(recipe.name),
subtitle: Text(recipe.cuisine),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => favorite.toggle(recipe),
),
),
],
);
},
),
);
}
}5.4 — Badge Jumlah Favorit di AppBar
Widget Badge dari Material 3 menampilkan jumlah favorit di atas ikon hati. Badge dibuat sebagai widget terpisah, FavoriteBadgeButton, agar hanya bagian ini yang rebuild ketika jumlah favorit berubah. isLabelVisible: count > 0 menyembunyikan angka ketika belum ada favorit. Tombol ini sekaligus membuka halaman Favorit.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/pages/favorite_page.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
class FavoriteBadgeButton extends StatelessWidget {
const FavoriteBadgeButton({super.key});
@override
Widget build(BuildContext context) {
final count = context.watch<FavoriteProvider>().count;
return IconButton(
tooltip: 'Favorit saya',
icon: Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: const Icon(Icons.favorite),
),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const FavoritePage()),
),
);
}
}import 'package:flutter/material.dart';
import 'package:flutter_application_1/data/dummy_recipes.dart';
import 'package:flutter_application_1/widgets/favorite_badge_button.dart';
import 'package:flutter_application_1/widgets/recipe_tile.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ResepKu'),
actions: const [FavoriteBadgeButton()],
),
body: ListView(
children: [
for (final recipe in dummyRecipes) RecipeTile(recipe: recipe),
],
),
);
}
}Bagian C: Pengujian Aplikasi
Setelah hot restart, enam skenario Langkah C.7 diuji. Semua skenario memberikan hasil sesuai harapan.
Tap ikon hati pada Margherita dan Alfredo di Home. Harapan: ikon berwarna merah, badge menunjukkan angka 2. ✓ Sesuai.
Buka detail Margherita. Harapan: ikon hati di AppBar terisi. ✓ Sesuai.
Di halaman detail, tap ikon hati, lalu kembali ke Home. Harapan: ikon Margherita kosong, badge menunjukkan angka 1. ✓ Sesuai.
Tap badge di AppBar. Harapan: halaman Favorit hanya berisi Alfredo. ✓ Sesuai.
Hapus Alfredo dari halaman Favorit, lalu kembali. Harapan: muncul pesan "Belum ada resep favorit", badge hilang. ✓ Sesuai.
Ubah porsi menjadi 2, kembali ke Home, lalu buka lagi resep yang sama. Harapan: porsi kembali ke nilai awal, karena state lokal ikut dibuang ketika halaman ditutup. ✓ Sesuai.
6.1 — Checkpoint 2
Home menampilkan Margherita dan Alfredo dengan ikon hati merah dan badge bernilai 2; nilai badge berasal dari FavoriteProvider.count yang dibaca FavoriteBadgeButton dengan context.watch. Halaman detail Margherita menampilkan ikon hati terisi karena membaca status favorit dari provider yang sama. Halaman Favorit berisi kedua resep tersebut. Ketiga halaman konsisten karena membaca satu sumber data.
VLatihan dan Tugas
Pertanyaan Analisis
Jawaban tiga pertanyaan analisis pada modul.
#1 1. Jawaban Tiga Pertanyaan Langkah B.2
Jawaban lengkap beserta alasannya dituliskan pada bagian Bagian B: Eksperimen Favorit dengan State Lokal. Ringkasnya: (a) ikon hati berubah karena setState() membangun ulang tile yang ditekan; (b) jumlah favorit tidak dapat ditampilkan di AppBar HomePage karena _isFavorite tersimpan secara private di masing-masing _RecipeTileState dan induk tidak dapat membaca State anaknya; (c) halaman detail tidak dapat mengetahui status favorit karena hanya menerima objek Recipe yang tidak memuat informasi favorit. Ketiganya menunjukkan bahwa state lokal hanya cocok untuk data yang dipakai oleh satu widget.
#2 2. Mengapa Jumlah Porsi Tetap Memakai setState()
Jumlah porsi adalah state lokal (ephemeral): hanya dibutuhkan oleh DetailPage dan hanya bermakna selama halaman itu terbuka. Tidak ada widget lain, misalnya daftar resep, badge, atau halaman Favorit, yang perlu membacanya. Jika dimasukkan ke Provider, nilai porsi justru akan bertahan setelah halaman ditutup, sesuatu yang tidak diinginkan, dan kode menjadi lebih rumit tanpa manfaat. Hal ini terbukti pada skenario 6 Langkah C.7: setelah porsi diubah menjadi 2 dan pengguna kembali ke Home, route DetailPage dikeluarkan dari Navigator dan objek _DetailPageState ikut dibuang (dispose). Ketika resep yang sama dibuka lagi, State baru dibuat dan initState() mengisi ulang _servings dari widget.recipe.servings, sehingga porsi kembali ke nilai awal (4 untuk Margherita). Perilaku ini sesuai: porsi adalah penyesuaian sementara, sedangkan favorit adalah data aplikasi yang harus bertahan lintas halaman.
#3 3. Memindahkan ChangeNotifierProvider ke HomePage
Untuk percobaan ini, baris FavoriteProvider di MultiProvider pada main.dart dijadikan komentar, lalu ChangeNotifierProvider dipasang di dalam HomePage membungkus Scaffold.
providers: [
// ChangeNotifierProvider(create: (context) => FavoriteProvider()), // A3: dipindah ke HomePage
ChangeNotifierProvider(create: (context) => ThemeProvider()),
], // A3: provider dipindah ke sini, membungkus Scaffold
return ChangeNotifierProvider(
create: (context) => FavoriteProvider(),
child: Scaffold(
appBar: AppBar( ... ),
body: ListView( ... ),
),
);Halaman Home masih tampil normal, karena RecipeTile dan badge di AppBar berada di bawah provider. Namun, halaman lain yang membaca FavoriteProvider langsung gagal dengan layar error merah ProviderNotFoundException. Saat sebuah resep diketuk, muncul pesan "Could not find the correct Provider<FavoriteProvider> above this DetailPage Widget", karena DetailPage memanggil context.watch<FavoriteProvider>(). Saat badge ditekan untuk membuka halaman Favorit, muncul pesan yang sama untuk widget Consumer<FavoriteProvider>. Pesan error tampil bertumpuk karena halaman Favorit memiliki dua Consumer, yaitu tombol Hapus semua di AppBar dan isi daftar di body, dan keduanya sama-sama gagal menemukan provider.
Penyebabnya adalah posisi provider di widget tree. context.watch dan Consumer mencari provider dengan menelusuri ancestor. Navigator.push tidak menaruh halaman baru di dalam HomePage, tetapi sebagai route baru yang menjadi anak Navigator, sejajar dengan route HomePage. Navigator dibuat oleh MaterialApp, yang posisinya di atas HomePage. Akibatnya, provider yang dipasang di dalam HomePage bukan ancestor dari FavoritePage dan tidak dapat ditemukan.
MultiProvider (FavoriteProvider, ThemeProvider) <- dipasang di main()
└─ MaterialApp
└─ IdentityFooter (builder)
└─ Navigator
├─ route: HomePage ✓ provider ditemukan
├─ route: DetailPage ✓ provider ditemukan
└─ route: FavoritePage ✓ provider ditemukanMultiProvider (ThemeProvider)
└─ MaterialApp
└─ IdentityFooter (builder)
└─ Navigator
├─ route: HomePage
│ └─ ChangeNotifierProvider<FavoriteProvider> <- dipindah ke sini
│ └─ Scaffold (badge, RecipeTile) ✓
├─ route: DetailPage ✗ FavoriteProvider tidak ditemukan
└─ route: FavoritePage ✗ FavoriteProvider tidak ditemukanSetelah percobaan, ChangeNotifierProvider dikembalikan ke dalam MultiProvider di main() seperti semula, dan aplikasi kembali berjalan normal.
Latihan 1: Tema Gelap dan Terang
Pengaturan tema disimpan sebagai app state di class ThemeProvider karena memengaruhi seluruh aplikasi. ThemeProvider didaftarkan di MultiProvider bersama FavoriteProvider. Halaman Pengaturan menampilkan SwitchListTile "Mode gelap" yang memanggil setDarkMode(), lalu notifyListeners() memberi tahu MaterialApp untuk membangun ulang aplikasi dengan tema baru.
themeMode dibaca dengan watch atau read? Jawabannya watch. MaterialApp harus ikut dibangun ulang setiap kali tema berubah, dan hanya context.watch yang mendaftarkan widget sebagai listener. Jika memakai context.read, nilai themeMode hanya dibaca sekali saat build() pertama, sehingga menggeser switch tidak akan mengubah tampilan aplikasi.
#1 ThemeProvider
Class ini menyimpan field ThemeMode, getter themeMode dan isDark, serta method setDarkMode() untuk mengubahnya. Import-nya memakai material.dart karena ThemeMode tidak tersedia di foundation.dart.
import 'package:flutter/material.dart';
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
ThemeMode get themeMode => _themeMode;
bool get isDark => _themeMode == ThemeMode.dark;
void setDarkMode(bool value) {
_themeMode = value ? ThemeMode.dark : ThemeMode.light;
notifyListeners(); // MaterialApp & SettingsPage ikut rebuild
}
}#2 main.dart
ThemeProvider ditambahkan ke MultiProvider, dan MaterialApp diberi darkTheme serta themeMode yang dibaca dengan context.watch. Baris builder untuk footer identitas tetap dipertahankan.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/pages/home_page.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
import 'package:flutter_application_1/providers/theme_provider.dart';
import 'package:flutter_application_1/widgets/identity_footer.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => FavoriteProvider()),
ChangeNotifierProvider(create: (context) => ThemeProvider()),
],
child: const ResepKuApp(),
),
);
}
class ResepKuApp extends StatelessWidget {
const ResepKuApp({super.key});
@override
Widget build(BuildContext context) {
// watch: MaterialApp harus rebuild setiap kali tema berubah
final themeMode = context.watch<ThemeProvider>().themeMode;
return MaterialApp(
title: 'ResepKu',
debugShowCheckedModeBanner: false,
theme: ThemeData(colorSchemeSeed: Colors.deepOrange),
darkTheme: ThemeData(
colorSchemeSeed: Colors.deepOrange,
brightness: Brightness.dark,
),
themeMode: themeMode,
builder: (context, child) =>
IdentityFooter(child: child!), // tetap dipertahankan
home: const HomePage(),
);
}
}#3 Halaman Pengaturan
Nilai switch dibaca dengan context.watch<ThemeProvider>().isDark, sedangkan onChanged memanggil context.read<ThemeProvider>().setDarkMode(value).
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/providers/theme_provider.dart';
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context) {
final isDark = context.watch<ThemeProvider>().isDark;
return Scaffold(
appBar: AppBar(title: const Text('Pengaturan')),
body: ListView(
children: [
SwitchListTile(
title: const Text('Mode gelap'),
secondary: const Icon(Icons.dark_mode),
value: isDark,
onChanged: (value) =>
context.read<ThemeProvider>().setDarkMode(value),
),
],
),
);
}
}#4 Tombol Pengaturan di HomePage
Tombol ikon pengaturan ditambahkan di AppBar HomePage untuk membuka SettingsPage. Karena IconButton memiliki callback, list actions tidak lagi bertanda const; kata const dipindahkan ke FavoriteBadgeButton() saja.
import 'package:flutter/material.dart';
import 'package:flutter_application_1/data/dummy_recipes.dart';
import 'package:flutter_application_1/pages/settings_page.dart';
import 'package:flutter_application_1/widgets/favorite_badge_button.dart';
import 'package:flutter_application_1/widgets/recipe_tile.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('ResepKu'),
actions: [
const FavoriteBadgeButton(),
IconButton(
tooltip: 'Pengaturan',
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsPage()),
),
),
],
),
body: ListView(
children: [
for (final recipe in dummyRecipes) RecipeTile(recipe: recipe),
],
),
);
}
}Latihan 2: Menghapus Semua Favorit
Tombol "Hapus semua" ditambahkan pada AppBar halaman Favorit. Tombol dibungkus Consumer<FavoriteProvider> agar statusnya ikut diperbarui ketika jumlah favorit berubah. Saat count bernilai 0, onPressed diisi null sehingga tombol otomatis nonaktif. Saat ditekan, showDialog menampilkan AlertDialog konfirmasi yang mengembalikan true (Hapus) atau false (Batal). Method clear() dari FavoriteProvider hanya dipanggil bila pengguna memilih Hapus. Pemeriksaan context.mounted memastikan context masih valid setelah menunggu dialog ditutup.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_application_1/providers/favorite_provider.dart';
class FavoritePage extends StatelessWidget {
const FavoritePage({super.key});
Future<void> _confirmClear(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Hapus semua favorit?'),
content: const Text('Semua resep akan dihapus dari daftar favorit.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Hapus'),
),
],
),
);
// read: di callback, cukup panggil method tanpa listen
if (confirmed == true && context.mounted) {
context.read<FavoriteProvider>().clear();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Favorit Saya'),
actions: [
Consumer<FavoriteProvider>(
builder: (context, favorite, child) => TextButton.icon(
// null = tombol nonaktif ketika daftar kosong
onPressed:
favorite.count == 0 ? null : () => _confirmClear(context),
icon: const Icon(Icons.delete_sweep),
label: const Text('Hapus semua'),
),
),
],
),
body: Consumer<FavoriteProvider>(
builder: (context, favorite, child) {
if (favorite.count == 0) {
return const Center(
child: Text(
'Belum ada resep favorit.\nTekan ikon hati di daftar resep.',
textAlign: TextAlign.center,
),
);
}
return ListView(
children: [
for (final recipe in favorite.items)
ListTile(
title: Text(recipe.name),
subtitle: Text(recipe.cuisine),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => favorite.toggle(recipe),
),
),
],
);
},
),
);
}
}VIKesimpulan
Dari Praktikum 6 ini, dapat ditarik beberapa kesimpulan:
Tempat menyimpan state ditentukan oleh siapa saja yang membutuhkannya. Jumlah porsi hanya dibutuhkan halaman detail dan bersifat sementara, sehingga disimpan sebagai state lokal di
_DetailPageStatedengansetState().Daftar favorit dibutuhkan oleh daftar resep, badge, halaman detail, dan halaman Favorit, sehingga disimpan sebagai app state di
FavoriteProvider. Eksperimen Bagian B membuktikan state lokal di setiap tile tidak dapat dibaca oleh widget lain.context.watchdipakai di dalambuild()agar widget ikut rebuild ketika data berubah, sedangkancontext.readdipakai di dalam callback untuk memanggil method.Consumermembatasi rebuild hanya pada bagian yang dibungkus.Provider harus berada di atas semua halaman yang membacanya. Karena halaman baru di-push sebagai anak
NavigatormilikMaterialApp, provider untuk data lintas halaman dipasang di atasMaterialApp; bila dipasang di dalam satu halaman, halaman lain memunculkanProviderNotFoundException.Perubahan pada
main(), seperti menambah provider baru, membutuhkan hot restart, karena hot reload tidak menjalankan ulangmain().