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
- Kenapa status login sebaiknya disimpan di Provider, bukan hanya di halaman?
- Jika user mengetik URL
/profilesaat belum login, seharusnya terjadi apa? - Apa beda “push page baru” dengan “replace page” dalam proses login/logout?
- 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
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)
- Buka aplikasi -> harus masuk ke
/login. - Coba akses
/profilelewat URL: - harus redirect ke
/login. - Login dengan akun dummy:
- redirect ke
/profileatau/products(sesuai router). - Setelah login, coba buka
/login: - harus redirect balik ke halaman setelah login.
- Logout:
- otomatis balik ke
/login.
Tugas (Tanpa Kode Jawaban)
Tugas Dasar
- Tambahkan tombol “Masuk sebagai Admin” yang mengisi email+password dummy admin otomatis (tanpa login langsung).
- Tambahkan validasi tambahan:
- email wajib domain
@smk.id(misal:admin@smk.id).
Tugas Industri (Lebih Sulit)
- Buat role
admindanuser(dummy): - admin boleh akses
/data-product - user tidak boleh akses
/data-product(redirect ke/products). - Tambahkan fitur “Remember me” (dummy):
- saat dicentang, setelah restart app status login tetap tersimpan (boleh pakai
shared_preferences). - Tambahkan halaman
/forbiddenuntuk user yang tidak punya akses.
Refleksi
- Apa fungsi
refreshListenable: authpada go_router? - Apa bedanya
context.go()dancontext.push()untuk alur login? - Jika login state berubah, kenapa router bisa ikut berubah tanpa dipanggil manual?