Lewati ke isi

Auth Provider

Tujuan Pembelajaran

Siswa mampu:
1. Membuat UI Login dan Register yang rapi.
2. Membuat AuthProvider untuk mengelola state login.
3. Membuat logout dari halaman Profile.
4. Membuat validasi halaman (route guard) menggunakan go_router:
- Belum login hanya boleh akses /login dan /register
- Sudah login tidak boleh kembali ke /login atau /register
- Logout otomatis balik ke /login


Pertanyaan Pemantik

  1. Kenapa status login sebaiknya disimpan di Provider, bukan hanya di halaman?
  2. Jika user mengetik URL /profile saat belum login, seharusnya terjadi apa?
  3. Apa beda “push page baru” dengan “replace page” dalam proses login/logout?
  4. Kalau nanti Auth memakai Firebase, bagian mana yang berubah?

Dependency

Pastikan di pubspec.yaml sudah ada:

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


Struktur Folder

Tambahkan/rapikan struktur berikut:

lib/
 ├── models/
 │   └── user_model.dart
 ├── data/
 │   └── dummy_users.dart
 ├── providers/
 │   └── auth_provider.dart
 ├── pages/
 │   ├── login_page.dart
 │   ├── register_page.dart
 │   └── profile_page.dart
 ├── routes/
 │   ├── app_routes.dart
 │   └── app_router.dart
 └── main.dart

Praktik Utama (Step-by-step)

Step 1 — Model User

File: lib/models/user_model.dart

class UserModel {
  final String id;
  final String name;
  final String email;
  final String password; // dummy only

  const UserModel({
    required this.id,
    required this.name,
    required this.email,
    required this.password,
  });
}

Catatan:
- password hanya untuk latihan dummy.
- Jika sudah Firebase, password tidak disimpan di model.


Step 2 — Dummy Users (agar tidak repot register)

File: lib/data/dummy_users.dart

import '../models/user_model.dart';

const List<UserModel> dummyUsers = [
  UserModel(
    id: 'u1',
    name: 'Admin SMK',
    email: 'admin@smk.id',
    password: 'admin123',
  ),
  UserModel(
    id: 'u2',
    name: 'Siswa 1',
    email: 'siswa1@smk.id',
    password: 'siswa123',
  ),
  UserModel(
    id: 'u3',
    name: 'Siswa 2',
    email: 'siswa2@smk.id',
    password: 'siswa123',
  ),
];

Akun yang bisa dipakai: - admin@smk.id / admin123 - siswa1@smk.id / siswa123 - siswa2@smk.id / siswa123


Step 3 — AuthProvider (login tidak asal masuk)

File: lib/providers/auth_provider.dart

import 'package:flutter/material.dart';
import '../data/dummy_users.dart';
import '../models/user_model.dart';

class AuthProvider extends ChangeNotifier {
  UserModel? _user;
  bool _isLoading = false;
  String? _errorMessage;

  UserModel? get user => _user;
  bool get isLoggedIn => _user != null;

  bool get isLoading => _isLoading;
  String? get errorMessage => _errorMessage;

  Future<bool> login({
    required String email,
    required String password,
  }) async {
    _errorMessage = null;
    _setLoading(true);
    notifyListeners();

    await Future.delayed(const Duration(milliseconds: 700));

    final e = email.trim().toLowerCase();
    final p = password.trim();

    // Validasi dasar
    if (e.isEmpty || p.isEmpty) {
      _setLoading(false);
      _errorMessage = 'Email dan password wajib diisi.';
      notifyListeners();
      return false;
    }
    if (!e.contains('@') || !e.contains('.')) {
      _setLoading(false);
      _errorMessage = 'Format email tidak valid.';
      notifyListeners();
      return false;
    }
    if (p.length < 6) {
      _setLoading(false);
      _errorMessage = 'Password minimal 6 karakter.';
      notifyListeners();
      return false;
    }

    // Cek dummy user
    final match = dummyUsers.where((u) => u.email.toLowerCase() == e).toList();
    if (match.isEmpty) {
      _setLoading(false);
      _errorMessage = 'Akun tidak ditemukan. Gunakan akun dummy.';
      notifyListeners();
      return false;
    }

    final u = match.first;
    if (u.password != p) {
      _setLoading(false);
      _errorMessage = 'Password salah.';
      notifyListeners();
      return false;
    }

    _user = u;
    _setLoading(false);
    notifyListeners();
    return true;
  }

  // Register hanya latihan UI + validasi
  Future<bool> registerDummy({
    required String name,
    required String email,
    required String password,
    required String confirmPassword,
  }) async {
    _errorMessage = null;
    _setLoading(true);
    notifyListeners();

    await Future.delayed(const Duration(milliseconds: 700));

    final n = name.trim();
    final e = email.trim().toLowerCase();
    final p = password.trim();
    final cp = confirmPassword.trim();

    if (n.length < 3) {
      _setLoading(false);
      _errorMessage = 'Nama minimal 3 karakter.';
      notifyListeners();
      return false;
    }
    if (!e.contains('@') || !e.contains('.')) {
      _setLoading(false);
      _errorMessage = 'Format email tidak valid.';
      notifyListeners();
      return false;
    }
    if (p.length < 6) {
      _setLoading(false);
      _errorMessage = 'Password minimal 6 karakter.';
      notifyListeners();
      return false;
    }
    if (p != cp) {
      _setLoading(false);
      _errorMessage = 'Password dan konfirmasi tidak sama.';
      notifyListeners();
      return false;
    }

    // Karena dummyUsers const, kita tidak simpan beneran.
    // Supaya alur tetap terasa, kita auto-login dengan user sementara.
    _user = UserModel(id: 'temp', name: n, email: e, password: p);

    _setLoading(false);
    notifyListeners();
    return true;
  }

  void logout() {
    _user = null;
    _errorMessage = null;
    notifyListeners();
  }

  void clearError() {
    if (_errorMessage == null) return;
    _errorMessage = null;
    notifyListeners();
  }

  void _setLoading(bool v) {
    _isLoading = v;
  }
}


Step 4 — Routes Konstanta

File: lib/routes/app_routes.dart

Sesuaikan dengan route yang sudah ada di project Anda (products/cart/transaction).
Tambahkan minimal ini:

class AppRoutes {
  static const loginName = 'login';
  static const registerName = 'register';
  static const profileName = 'profile';

  static const loginPath = '/login';
  static const registerPath = '/register';
  static const profilePath = '/profile';

  // Jika sudah ada:
  // static const productsPath = '/products';
  // static const cartPath = '/cart';
  // static const transactionPath = '/transaction';
}

Step 5 — go_router + Route Guard

Prinsip: - Router harus “refresh” saat auth berubah → pakai refreshListenable: auth. - redirect dipakai untuk validasi halaman.

File: lib/routes/app_router.dart

import 'package:go_router/go_router.dart';

import '../providers/auth_provider.dart';
import '../pages/login_page.dart';
import '../pages/register_page.dart';
import '../pages/profile_page.dart';
import 'app_routes.dart';

// Pastikan router dibuat dengan parameter auth (tanpa context global)
GoRouter createRouter(AuthProvider auth) {
  return GoRouter(
    initialLocation: AppRoutes.loginPath,
    refreshListenable: auth,

    redirect: (context, state) {
      final loggedIn = auth.isLoggedIn;

      final location = state.matchedLocation;
      final goingToLogin = location == AppRoutes.loginPath;
      final goingToRegister = location == AppRoutes.registerPath;

      // Belum login -> hanya boleh /login dan /register
      if (!loggedIn) {
        if (goingToLogin || goingToRegister) return null;
        return AppRoutes.loginPath;
      }

      // Sudah login -> tidak boleh kembali ke /login /register
      if (loggedIn) {
        if (goingToLogin || goingToRegister) {
          // Jika project Anda punya productsPath, arahkan kesana
          // return AppRoutes.productsPath;
          return AppRoutes.profilePath; // fallback aman
        }
      }

      return null;
    },

    routes: [
      GoRoute(
        name: AppRoutes.loginName,
        path: AppRoutes.loginPath,
        builder: (context, state) => const LoginPage(),
      ),
      GoRoute(
        name: AppRoutes.registerName,
        path: AppRoutes.registerPath,
        builder: (context, state) => const RegisterPage(),
      ),
      GoRoute(
        name: AppRoutes.profileName,
        path: AppRoutes.profilePath,
        builder: (context, state) => const ProfilePage(),
      ),

      // Tambahkan route lain yang sudah ada di project:
      // products, cart, transaction, data-product, dll
    ],
  );
}


Step 6 — Daftarkan Provider dan Router di main.dart

File: lib/main.dart

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

import 'providers/auth_provider.dart';
import 'routes/app_router.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AuthProvider()),
        // provider lain: ProductProvider, CartProvider, TransactionProvider, dll
      ],
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    final auth = context.read<AuthProvider>();
    final router = createRouter(auth);

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


UI Pages

Step 7 — Login Page (UI rapi + akun dummy)

File: lib/pages/login_page.dart

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

import '../providers/auth_provider.dart';
import '../routes/app_routes.dart';
import '../data/dummy_users.dart';

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  final _emailC = TextEditingController();
  final _passC = TextEditingController();

  bool _obscure = true;

  @override
  void dispose() {
    _emailC.dispose();
    _passC.dispose();
    super.dispose();
  }

  void fillDummy(int index) {
    final u = dummyUsers[index];
    _emailC.text = u.email;
    _passC.text = u.password;
    context.read<AuthProvider>().clearError();
    setState(() {});
  }

  Future<void> submit() async {
    FocusScope.of(context).unfocus();
    if (!(_formKey.currentState?.validate() ?? false)) return;

    final auth = context.read<AuthProvider>();
    final ok = await auth.login(email: _emailC.text, password: _passC.text);

    if (!mounted) return;
    if (ok) {
      // Pindah halaman cukup biarkan redirect router bekerja
      // Tapi boleh juga push/replace ke profile/products:
      context.goNamed(AppRoutes.profileName);
    }
  }

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthProvider>();
    final cs = Theme.of(context).colorScheme;

    return Scaffold(
      body: SafeArea(
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 520),
            child: ListView(
              padding: const EdgeInsets.all(20),
              children: [
                Container(
                  padding: const EdgeInsets.all(18),
                  decoration: BoxDecoration(
                    color: cs.primaryContainer,
                    borderRadius: BorderRadius.circular(18),
                  ),
                  child: Row(
                    children: [
                      Container(
                        width: 46,
                        height: 46,
                        decoration: BoxDecoration(
                          color: cs.primary,
                          borderRadius: BorderRadius.circular(14),
                        ),
                        child: Icon(Icons.lock_outline, color: cs.onPrimary),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text('Login', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 16, color: cs.onPrimaryContainer)),
                            const SizedBox(height: 4),
                            Text('Gunakan akun dummy untuk latihan auth + route guard.', style: TextStyle(color: cs.onPrimaryContainer.withOpacity(0.85))),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(height: 16),

                _DummyBox(onPick: fillDummy),
                const SizedBox(height: 14),

                Form(
                  key: _formKey,
                  child: Column(
                    children: [
                      TextFormField(
                        controller: _emailC,
                        keyboardType: TextInputType.emailAddress,
                        decoration: const InputDecoration(
                          labelText: 'Email',
                          border: OutlineInputBorder(),
                        ),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s.isEmpty) return 'Email wajib diisi';
                          if (!s.contains('@') || !s.contains('.')) return 'Format email tidak valid';
                          return null;
                        },
                      ),
                      const SizedBox(height: 12),
                      TextFormField(
                        controller: _passC,
                        obscureText: _obscure,
                        decoration: InputDecoration(
                          labelText: 'Password',
                          border: const OutlineInputBorder(),
                          suffixIcon: IconButton(
                            onPressed: () => setState(() => _obscure = !_obscure),
                            icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
                          ),
                        ),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s.isEmpty) return 'Password wajib diisi';
                          if (s.length < 6) return 'Password minimal 6 karakter';
                          return null;
                        },
                      ),

                      if (auth.errorMessage != null) ...[
                        const SizedBox(height: 10),
                        Container(
                          width: double.infinity,
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            borderRadius: BorderRadius.circular(12),
                            color: Colors.red.withOpacity(0.08),
                          ),
                          child: Text(
                            auth.errorMessage!,
                            style: const TextStyle(color: Colors.red, fontWeight: FontWeight.w700),
                          ),
                        ),
                      ],

                      const SizedBox(height: 14),
                      SizedBox(
                        width: double.infinity,
                        height: 48,
                        child: ElevatedButton(
                          onPressed: auth.isLoading ? null : submit,
                          child: auth.isLoading
                              ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2))
                              : const Text('Login'),
                        ),
                      ),
                      const SizedBox(height: 10),
                      OutlinedButton(
                        onPressed: () => context.goNamed(AppRoutes.registerName),
                        child: const Text('Register (latihan)'),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _DummyBox extends StatelessWidget {
  final void Function(int index) onPick;
  const _DummyBox({required this.onPick});

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;

    return Container(
      padding: const EdgeInsets.all(14),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: cs.outlineVariant),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('Akun Dummy', style: TextStyle(fontWeight: FontWeight.w900)),
          const SizedBox(height: 8),
          ...List.generate(dummyUsers.length, (i) {
            final u = dummyUsers[i];
            return InkWell(
              onTap: () => onPick(i),
              borderRadius: BorderRadius.circular(12),
              child: Container(
                padding: const EdgeInsets.all(12),
                margin: const EdgeInsets.only(bottom: 8),
                decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(12),
                  color: cs.surfaceContainerHighest.withOpacity(0.6),
                ),
                child: Row(
                  children: [
                    CircleAvatar(
                      backgroundColor: cs.primaryContainer,
                      child: Icon(Icons.person, color: cs.onPrimaryContainer),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(u.name, style: const TextStyle(fontWeight: FontWeight.w800)),
                          const SizedBox(height: 2),
                          Text(u.email, style: TextStyle(color: cs.onSurfaceVariant)),
                        ],
                      ),
                    ),
                    const Icon(Icons.arrow_forward_ios_rounded, size: 16),
                  ],
                ),
              ),
            );
          }),
        ],
      ),
    );
  }
}


Step 8 — Register Page (latihan UI + validasi)

File: lib/pages/register_page.dart

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

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

class RegisterPage extends StatefulWidget {
  const RegisterPage({super.key});

  @override
  State<RegisterPage> createState() => _RegisterPageState();
}

class _RegisterPageState extends State<RegisterPage> {
  final _formKey = GlobalKey<FormState>();
  final _nameC = TextEditingController();
  final _emailC = TextEditingController();
  final _passC = TextEditingController();
  final _confirmC = TextEditingController();
  bool _obscure = true;

  @override
  void dispose() {
    _nameC.dispose();
    _emailC.dispose();
    _passC.dispose();
    _confirmC.dispose();
    super.dispose();
  }

  Future<void> submit() async {
    FocusScope.of(context).unfocus();
    if (!(_formKey.currentState?.validate() ?? false)) return;

    final auth = context.read<AuthProvider>();
    final ok = await auth.registerDummy(
      name: _nameC.text,
      email: _emailC.text,
      password: _passC.text,
      confirmPassword: _confirmC.text,
    );

    if (!mounted) return;
    if (ok) context.goNamed(AppRoutes.profileName);
  }

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthProvider>();

    return Scaffold(
      appBar: AppBar(title: const Text('Register (Latihan)')),
      body: SafeArea(
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 520),
            child: ListView(
              padding: const EdgeInsets.all(20),
              children: [
                Container(
                  padding: const EdgeInsets.all(14),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(16),
                    color: Theme.of(context).colorScheme.secondaryContainer,
                  ),
                  child: Text(
                    'Catatan: Register ini hanya latihan UI + validasi. Login utama pakai akun dummy.',
                    style: TextStyle(fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSecondaryContainer),
                  ),
                ),
                const SizedBox(height: 16),

                Form(
                  key: _formKey,
                  child: Column(
                    children: [
                      TextFormField(
                        controller: _nameC,
                        decoration: const InputDecoration(labelText: 'Nama', border: OutlineInputBorder()),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s.length < 3) return 'Nama minimal 3 karakter';
                          return null;
                        },
                      ),
                      const SizedBox(height: 12),
                      TextFormField(
                        controller: _emailC,
                        keyboardType: TextInputType.emailAddress,
                        decoration: const InputDecoration(labelText: 'Email', border: OutlineInputBorder()),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s.isEmpty) return 'Email wajib diisi';
                          if (!s.contains('@') || !s.contains('.')) return 'Format email tidak valid';
                          return null;
                        },
                      ),
                      const SizedBox(height: 12),
                      TextFormField(
                        controller: _passC,
                        obscureText: _obscure,
                        decoration: InputDecoration(
                          labelText: 'Password',
                          border: const OutlineInputBorder(),
                          suffixIcon: IconButton(
                            onPressed: () => setState(() => _obscure = !_obscure),
                            icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
                          ),
                        ),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s.length < 6) return 'Password minimal 6 karakter';
                          return null;
                        },
                      ),
                      const SizedBox(height: 12),
                      TextFormField(
                        controller: _confirmC,
                        obscureText: _obscure,
                        decoration: const InputDecoration(labelText: 'Konfirmasi Password', border: OutlineInputBorder()),
                        onChanged: (_) => auth.clearError(),
                        validator: (v) {
                          final s = (v ?? '').trim();
                          if (s != _passC.text.trim()) return 'Konfirmasi tidak sama';
                          return null;
                        },
                      ),

                      if (auth.errorMessage != null) ...[
                        const SizedBox(height: 12),
                        Container(
                          width: double.infinity,
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            color: Colors.red.withOpacity(0.08),
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: Text(auth.errorMessage!, style: const TextStyle(color: Colors.red, fontWeight: FontWeight.w700)),
                        ),
                      ],

                      const SizedBox(height: 14),
                      SizedBox(
                        width: double.infinity,
                        height: 48,
                        child: ElevatedButton(
                          onPressed: auth.isLoading ? null : submit,
                          child: auth.isLoading
                              ? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2))
                              : const Text('Register'),
                        ),
                      ),
                      const SizedBox(height: 10),
                      TextButton(
                        onPressed: () => context.goNamed(AppRoutes.loginName),
                        child: const Text('Kembali ke Login'),
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}


Step 9 — Profile Page + Logout

Logout harus memanggil provider, setelah itu router akan redirect otomatis.

File: lib/pages/profile_page.dart

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

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

  Future<void> confirmLogout(BuildContext context) async {
    final ok = await showDialog<bool>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Logout'),
        content: const Text('Yakin ingin keluar?'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Batal')),
          ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Logout')),
        ],
      ),
    );

    if (ok == true) {
      context.read<AuthProvider>().logout();
    }
  }

  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthProvider>();
    final user = auth.user;
    final cs = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: SafeArea(
        child: Center(
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 720),
            child: ListView(
              padding: const EdgeInsets.all(20),
              children: [
                Container(
                  padding: const EdgeInsets.all(18),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(18),
                    color: cs.primaryContainer,
                  ),
                  child: Row(
                    children: [
                      CircleAvatar(
                        radius: 30,
                        backgroundColor: cs.primary,
                        child: Icon(Icons.person, color: cs.onPrimary, size: 34),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(user?.name ?? '-', style: TextStyle(fontWeight: FontWeight.w900, fontSize: 18, color: cs.onPrimaryContainer)),
                            const SizedBox(height: 4),
                            Text(user?.email ?? '-', style: TextStyle(color: cs.onPrimaryContainer.withOpacity(0.85))),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
                const SizedBox(height: 16),

                Container(
                  padding: const EdgeInsets.all(14),
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(16),
                    border: Border.all(color: cs.outlineVariant),
                  ),
                  child: const Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text('Pengaturan (Dummy)', style: TextStyle(fontWeight: FontWeight.w900)),
                      SizedBox(height: 10),
                      Text('• Notifikasi'),
                      Text('• Privasi'),
                      Text('• Tema'),
                    ],
                  ),
                ),

                const SizedBox(height: 18),
                SizedBox(
                  height: 48,
                  child: ElevatedButton.icon(
                    onPressed: () => confirmLogout(context),
                    icon: const Icon(Icons.logout),
                    label: const Text('Logout'),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}


Studi Kasus (Yang Harus Dicoba)

  1. Buka aplikasi -> harus masuk ke /login.
  2. Coba akses /profile lewat URL:
  3. harus redirect ke /login.
  4. Login dengan akun dummy:
  5. redirect ke /profile atau /products (sesuai router).
  6. Setelah login, coba buka /login:
  7. harus redirect balik ke halaman setelah login.
  8. Logout:
  9. otomatis balik ke /login.

Tugas (Tanpa Kode Jawaban)

Tugas Dasar

  1. Tambahkan tombol “Masuk sebagai Admin” yang mengisi email+password dummy admin otomatis (tanpa login langsung).
  2. Tambahkan validasi tambahan:
  3. email wajib domain @smk.id (misal: admin@smk.id).

Tugas Industri (Lebih Sulit)

  1. Buat role admin dan user (dummy):
  2. admin boleh akses /data-product
  3. user tidak boleh akses /data-product (redirect ke /products).
  4. Tambahkan fitur “Remember me” (dummy):
  5. saat dicentang, setelah restart app status login tetap tersimpan (boleh pakai shared_preferences).
  6. Tambahkan halaman /forbidden untuk user yang tidak punya akses.

Refleksi

  1. Apa fungsi refreshListenable: auth pada go_router?
  2. Apa bedanya context.go() dan context.push() untuk alur login?
  3. Jika login state berubah, kenapa router bisa ikut berubah tanpa dipanggil manual?