Skip to main content
Praktikum 6·
8 November 2026

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 lewat notifyListeners() setiap kali data berubah.

  • ChangeNotifierProvider: widget yang membuat dan menyediakan objek ChangeNotifier untuk widget di bawahnya. MultiProvider menggabungkan beberapa provider agar mudah ditambah.

  • context.watch<T>(): dipakai di dalam build(). Widget didaftarkan sebagai listener sehingga ikut rebuild ketika data berubah.

  • context.read<T>(): dipakai di dalam callback seperti onPressed. Hanya mengambil objek untuk memanggil method, tanpa mendengarkan perubahan.

  • Consumer<T>: bentuk widget dari context.watch. Fungsi builder-nya dijalankan ulang setiap kali notifyListeners() 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

Laptop (Windows)
Flutter SDK dan Dart
Visual Studio Code dengan extension Flutter
Terminal (flutter run, flutter pub add)
Emulator / perangkat uji untuk menjalankan aplikasi
Package provider ^6.1.5+1

IVLangkah Kerja Praktikum

1

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.

Struktur folder lib/ (akhir modul)
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.dart

1.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.

lib/models/recipe.dart
dart
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.

lib/data/dummy_recipes.dart
dart
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.

lib/widgets/identity_footer.dart
dart
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.

lib/main.dart (versi awal)
dart
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(),
    );
  }
}
lib/pages/home_page.dart (versi awal)
dart
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),
        ],
      ),
    );
  }
}
lib/widgets/recipe_tile.dart (versi awal)
dart
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)),
      ),
    );
  }
}
2

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.

lib/pages/detail_page.dart (versi awal)
dart
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.

lib/pages/detail_page.dart
dart
IconButton.filledTonal(
  onPressed: _servings > 1 ? _removeServing : null,   // baris 63
  icon: const Icon(Icons.remove),
),
3

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.

lib/widgets/recipe_tile.dart (versi eksperimen)
dart
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

  1. Apakah ikon hati berubah? Mengapa? Ya, ikon hati pada tile yang ditekan berubah dari garis (favorite_border) menjadi hati merah terisi (favorite). onPressed memanggil setState(() => _isFavorite = !_isFavorite) di dalam _RecipeTileState, sehingga tile tersebut dibangun ulang dengan nilai _isFavorite yang baru. Hanya tile yang ditekan yang berubah, karena setiap tile memiliki objek State sendiri.

  2. Bisakah jumlah favorit ditampilkan di judul AppBar HomePage? Tidak bisa. Data _isFavorite berada 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; HomePage sebagai induk hanya membuat RecipeTile(recipe: recipe) dan tidak memegang referensi ke State milik tile, sehingga tidak dapat membaca atau menjumlahkan status favorit tersebut.

  3. Bagaimana halaman detail dapat mengetahui bahwa resep tersebut favorit? Halaman detail tidak dapat mengetahuinya. DetailPage hanya menerima objek Recipe melalui konstruktor, dan model Recipe tidak memiliki field favorit. Status favorit tetap tinggal di _RecipeTileState pada halaman Home, yang merupakan route berbeda dan tidak terhubung dengan DetailPage.

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.

4

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.

Terminal
bash
flutter pub add provider
lib/providers/favorite_provider.dart
dart
import '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().

lib/main.dart
dart
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(),
    );
  }
}
5

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).

lib/widgets/recipe_tile.dart
dart
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).

Perubahan pada detail_page.dart
dart
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),
  ),
],
lib/pages/detail_page.dart
dart
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().

lib/pages/favorite_page.dart (sebelum Latihan 2)
dart
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.

lib/widgets/favorite_badge_button.dart
dart
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()),
      ),
    );
  }
}
lib/pages/home_page.dart (sebelum Latihan 1)
dart
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),
        ],
      ),
    );
  }
}
6

Bagian C: Pengujian Aplikasi

Setelah hot restart, enam skenario Langkah C.7 diuji. Semua skenario memberikan hasil sesuai harapan.

  1. Tap ikon hati pada Margherita dan Alfredo di Home. Harapan: ikon berwarna merah, badge menunjukkan angka 2. ✓ Sesuai.

  2. Buka detail Margherita. Harapan: ikon hati di AppBar terisi. ✓ Sesuai.

  3. Di halaman detail, tap ikon hati, lalu kembali ke Home. Harapan: ikon Margherita kosong, badge menunjukkan angka 1. ✓ Sesuai.

  4. Tap badge di AppBar. Harapan: halaman Favorit hanya berisi Alfredo. ✓ Sesuai.

  5. Hapus Alfredo dari halaman Favorit, lalu kembali. Harapan: muncul pesan "Belum ada resep favorit", badge hilang. ✓ Sesuai.

  6. 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

1

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.

lib/main.dart (percobaan)
dart
      providers: [
        // ChangeNotifierProvider(create: (context) => FavoriteProvider()), // A3: dipindah ke HomePage
        ChangeNotifierProvider(create: (context) => ThemeProvider()),
      ],
lib/pages/home_page.dart (percobaan)
dart
    // 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.

Widget tree: provider di main() (benar)
MultiProvider (FavoriteProvider, ThemeProvider)   <- dipasang di main()
 └─ MaterialApp
     └─ IdentityFooter (builder)
         └─ Navigator
             ├─ route: HomePage            ✓ provider ditemukan
             ├─ route: DetailPage          ✓ provider ditemukan
             └─ route: FavoritePage        ✓ provider ditemukan
Widget tree: provider di HomePage (error)
MultiProvider (ThemeProvider)
 └─ MaterialApp
     └─ IdentityFooter (builder)
         └─ Navigator
             ├─ route: HomePage
             │    └─ ChangeNotifierProvider<FavoriteProvider>   <- dipindah ke sini
             │         └─ Scaffold (badge, RecipeTile)          ✓
             ├─ route: DetailPage          ✗ FavoriteProvider tidak ditemukan
             └─ route: FavoritePage        ✗ FavoriteProvider tidak ditemukan

Setelah percobaan, ChangeNotifierProvider dikembalikan ke dalam MultiProvider di main() seperti semula, dan aplikasi kembali berjalan normal.

2

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.

lib/providers/theme_provider.dart
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.

lib/main.dart
dart
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).

lib/pages/settings_page.dart
dart
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.

lib/pages/home_page.dart
dart
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),
        ],
      ),
    );
  }
}
3

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.

lib/pages/favorite_page.dart
dart
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:

  1. Tempat menyimpan state ditentukan oleh siapa saja yang membutuhkannya. Jumlah porsi hanya dibutuhkan halaman detail dan bersifat sementara, sehingga disimpan sebagai state lokal di _DetailPageState dengan setState().

  2. 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.

  3. context.watch dipakai di dalam build() agar widget ikut rebuild ketika data berubah, sedangkan context.read dipakai di dalam callback untuk memanggil method. Consumer membatasi rebuild hanya pada bagian yang dibungkus.

  4. Provider harus berada di atas semua halaman yang membacanya. Karena halaman baru di-push sebagai anak Navigator milik MaterialApp, provider untuk data lintas halaman dipasang di atas MaterialApp; bila dipasang di dalam satu halaman, halaman lain memunculkan ProviderNotFoundException.

  5. Perubahan pada main(), seperti menambah provider baru, membutuhkan hot restart, karena hot reload tidak menjalankan ulang main().