Skip to content

Bab 28 — Firebase Authentication

Autentikasi adalah fitur yang terlihat sederhana dari luar dan ternyata penuh detail: validasi, penanganan kesalahan, status pemuatan, memulihkan sesi, mengarahkan pengguna ke layar yang benar. Firebase Authentication mengurus bagian yang sulit — menyimpan kata sandi dengan aman, mengelola token, menangani kedaluwarsa — dan menyisakan bagian yang menyenangkan untukmu.

Mengaktifkan di console

Sebelum apa pun berfungsi, aktifkan metodenya:

  1. Firebase Console → AuthenticationGet started.
  2. Tab Sign-in method.
  3. Aktifkan Email/Password.
  4. Aktifkan Google (isi email dukungan proyek).

Melewatkan langkah ini menghasilkan error operation-not-allowed yang cukup membingungkan karena kodemu terlihat benar.

Membungkus SDK di kelas layanan

Sesuai prinsip di Bab 27, jangan panggil FirebaseAuth langsung dari widget.

dart
// lib/data/services/layanan_auth.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:google_sign_in/google_sign_in.dart';

/// Kesalahan yang sudah diterjemahkan ke bahasa manusia.
class KesalahanAuth implements Exception {
  KesalahanAuth(this.pesan, {this.kode});

  final String pesan;
  final String? kode;

  @override
  String toString() => pesan;
}

class LayananAuth {
  LayananAuth({FirebaseAuth? auth, GoogleSignIn? google})
      : _auth = auth ?? FirebaseAuth.instance,
        _google = google ?? GoogleSignIn();

  final FirebaseAuth _auth;
  final GoogleSignIn _google;

  /// Stream yang mengeluarkan pengguna setiap kali status login berubah.
  Stream<User?> get perubahanStatus => _auth.authStateChanges();

  User? get penggunaSaatIni => _auth.currentUser;

  bool get sudahMasuk => _auth.currentUser != null;

  // --- Daftar dengan email ---
  Future<User> daftar({
    required String email,
    required String sandi,
    required String nama,
  }) async {
    try {
      final kredensial = await _auth.createUserWithEmailAndPassword(
        email: email.trim(),
        password: sandi,
      );

      final pengguna = kredensial.user!;

      // Simpan nama tampilan di profil Auth
      await pengguna.updateDisplayName(nama.trim());
      await pengguna.reload();

      return _auth.currentUser!;
    } on FirebaseAuthException catch (e) {
      throw KesalahanAuth(_terjemahkan(e.code), kode: e.code);
    }
  }

  // --- Masuk dengan email ---
  Future<User> masuk({
    required String email,
    required String sandi,
  }) async {
    try {
      final kredensial = await _auth.signInWithEmailAndPassword(
        email: email.trim(),
        password: sandi,
      );
      return kredensial.user!;
    } on FirebaseAuthException catch (e) {
      throw KesalahanAuth(_terjemahkan(e.code), kode: e.code);
    }
  }

  // --- Masuk dengan Google ---
  Future<User> masukDenganGoogle() async {
    try {
      final akun = await _google.signIn();

      // Pengguna menutup dialog pemilihan akun
      if (akun == null) {
        throw KesalahanAuth('Login dibatalkan.', kode: 'dibatalkan');
      }

      final autentikasi = await akun.authentication;

      final kredensial = GoogleAuthProvider.credential(
        accessToken: autentikasi.accessToken,
        idToken: autentikasi.idToken,
      );

      final hasil = await _auth.signInWithCredential(kredensial);
      return hasil.user!;
    } on FirebaseAuthException catch (e) {
      throw KesalahanAuth(_terjemahkan(e.code), kode: e.code);
    }
  }

  // --- Lupa kata sandi ---
  Future<void> kirimTautanResetSandi(String email) async {
    try {
      await _auth.sendPasswordResetEmail(email: email.trim());
    } on FirebaseAuthException catch (e) {
      throw KesalahanAuth(_terjemahkan(e.code), kode: e.code);
    }
  }

  // --- Keluar ---
  Future<void> keluar() async {
    await _google.signOut();   // penting: kalau tidak, akun Google tetap terikat
    await _auth.signOut();
  }

  // --- Hapus akun ---
  Future<void> hapusAkun() async {
    try {
      await _auth.currentUser?.delete();
    } on FirebaseAuthException catch (e) {
      if (e.code == 'requires-recent-login') {
        throw KesalahanAuth(
          'Demi keamanan, masuk ulang sebelum menghapus akun.',
          kode: e.code,
        );
      }
      throw KesalahanAuth(_terjemahkan(e.code), kode: e.code);
    }
  }

  /// Menerjemahkan kode kesalahan Firebase menjadi pesan Bahasa Indonesia.
  String _terjemahkan(String kode) {
    return switch (kode) {
      'email-already-in-use' => 'Email ini sudah terdaftar.',
      'invalid-email' => 'Format email tidak valid.',
      'weak-password' => 'Kata sandi terlalu lemah. Minimal 6 karakter.',
      'user-not-found' => 'Akun tidak ditemukan.',
      'wrong-password' => 'Kata sandi salah.',
      'invalid-credential' => 'Email atau kata sandi salah.',
      'user-disabled' => 'Akun ini telah dinonaktifkan.',
      'too-many-requests' =>
        'Terlalu banyak percobaan. Coba lagi beberapa saat.',
      'operation-not-allowed' =>
        'Metode login ini belum diaktifkan di server.',
      'network-request-failed' => 'Periksa koneksi internetmu.',
      'account-exists-with-different-credential' =>
        'Email ini sudah terdaftar dengan metode login lain.',
      _ => 'Terjadi kesalahan. Coba lagi.',
    };
  }
}

Beberapa detail yang layak diperhatikan:

_google.signOut() sebelum _auth.signOut(). Kalau kamu hanya keluar dari Firebase, akun Google tetap terpilih — sehingga login berikutnya langsung masuk tanpa memberi pengguna kesempatan memilih akun lain. Bug yang sangat membingungkan pengguna.

Terjemahan kode kesalahan. Menampilkan [firebase_auth/wrong-password] ke pengguna tidak membantu siapa pun. Peta ini mengubahnya menjadi kalimat yang bisa ditindaklanjuti.

invalid-credential. Sejak pembaruan keamanan Firebase, user-not-found dan wrong-password sering digabung menjadi kode ini — sengaja, agar penyerang tidak bisa menebak email mana yang terdaftar.

Menyimpan profil pengguna di Firestore

Firebase Auth hanya menyimpan email, nama tampilan, dan URL foto. Untuk data lain — nomor telepon, bio, peran, preferensi — kamu butuh Firestore.

dart
// lib/data/models/pengguna.dart
import 'package:cloud_firestore/cloud_firestore.dart';

class Pengguna {
  const Pengguna({
    required this.uid,
    required this.nama,
    required this.email,
    this.urlFoto,
    this.fcmToken,
    required this.dibuatPada,
  });

  final String uid;
  final String nama;
  final String email;
  final String? urlFoto;
  final String? fcmToken;
  final DateTime dibuatPada;

  factory Pengguna.dariDokumen(DocumentSnapshot<Map<String, dynamic>> doc) {
    final data = doc.data()!;
    return Pengguna(
      uid: doc.id,
      nama: data['nama'] as String? ?? 'Tanpa Nama',
      email: data['email'] as String? ?? '',
      urlFoto: data['urlFoto'] as String?,
      fcmToken: data['fcmToken'] as String?,
      dibuatPada:
          (data['dibuatPada'] as Timestamp?)?.toDate() ?? DateTime.now(),
    );
  }

  Map<String, dynamic> toMap() => {
        'nama': nama,
        'email': email,
        'urlFoto': urlFoto,
        'fcmToken': fcmToken,
        'dibuatPada': Timestamp.fromDate(dibuatPada),
      };

  Pengguna copyWith({String? nama, String? urlFoto, String? fcmToken}) {
    return Pengguna(
      uid: uid,
      nama: nama ?? this.nama,
      email: email,
      urlFoto: urlFoto ?? this.urlFoto,
      fcmToken: fcmToken ?? this.fcmToken,
      dibuatPada: dibuatPada,
    );
  }
}

Perhatikan Timestamp — tipe khusus Firestore untuk tanggal. Ia harus dikonversi ke DateTime saat dibaca dan sebaliknya saat ditulis. Ini setara dengan konversi DateTime di Bab 25.

Repository yang menggabungkan Auth dan Firestore:

dart
// lib/data/repositories/auth_repository.dart
import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import '../models/pengguna.dart';
import '../services/layanan_auth.dart';
import '../services/layanan_storage.dart';

class AuthRepository {
  AuthRepository({
    required LayananAuth layananAuth,
    required LayananStorage layananStorage,
    FirebaseFirestore? firestore,
  })  : _auth = layananAuth,
        _storage = layananStorage,
        _db = firestore ?? FirebaseFirestore.instance;

  final LayananAuth _auth;
  final LayananStorage _storage;
  final FirebaseFirestore _db;

  Stream<fb.User?> get perubahanStatus => _auth.perubahanStatus;

  CollectionReference<Map<String, dynamic>> get _koleksiPengguna =>
      _db.collection('users');

  /// Daftar: buat akun Auth, unggah foto, lalu simpan profil ke Firestore.
  Future<Pengguna> daftar({
    required String email,
    required String sandi,
    required String nama,
    File? foto,
  }) async {
    final userAuth = await _auth.daftar(
      email: email,
      sandi: sandi,
      nama: nama,
    );

    String? urlFoto;
    if (foto != null) {
      urlFoto = await _storage.unggahFotoProfil(userAuth.uid, foto);
      await userAuth.updatePhotoURL(urlFoto);
    }

    final profil = Pengguna(
      uid: userAuth.uid,
      nama: nama.trim(),
      email: email.trim(),
      urlFoto: urlFoto,
      dibuatPada: DateTime.now(),
    );

    // ID dokumen = UID, sehingga aturan keamanan menjadi sederhana
    await _koleksiPengguna.doc(userAuth.uid).set(profil.toMap());

    return profil;
  }

  Future<Pengguna> masuk({
    required String email,
    required String sandi,
  }) async {
    final userAuth = await _auth.masuk(email: email, sandi: sandi);
    return ambilProfil(userAuth.uid);
  }

  /// Login Google: buat profil kalau ini pertama kalinya.
  Future<Pengguna> masukDenganGoogle() async {
    final userAuth = await _auth.masukDenganGoogle();

    final doc = await _koleksiPengguna.doc(userAuth.uid).get();

    if (!doc.exists) {
      final profil = Pengguna(
        uid: userAuth.uid,
        nama: userAuth.displayName ?? 'Pengguna',
        email: userAuth.email ?? '',
        urlFoto: userAuth.photoURL,
        dibuatPada: DateTime.now(),
      );
      await _koleksiPengguna.doc(userAuth.uid).set(profil.toMap());
      return profil;
    }

    return Pengguna.dariDokumen(doc);
  }

  Future<Pengguna> ambilProfil(String uid) async {
    final doc = await _koleksiPengguna.doc(uid).get();

    if (!doc.exists) {
      throw KesalahanAuth('Profil pengguna tidak ditemukan.');
    }

    return Pengguna.dariDokumen(doc);
  }

  /// Stream profil — berguna kalau profil bisa diubah dari perangkat lain.
  Stream<Pengguna> aliranProfil(String uid) {
    return _koleksiPengguna
        .doc(uid)
        .snapshots()
        .where((doc) => doc.exists)
        .map(Pengguna.dariDokumen);
  }

  Future<void> perbaruiToken(String uid, String token) async {
    await _koleksiPengguna.doc(uid).update({'fcmToken': token});
  }

  Future<void> kirimTautanResetSandi(String email) =>
      _auth.kirimTautanResetSandi(email);

  Future<void> keluar() => _auth.keluar();
}

Layar autentikasi

Satu layar yang bisa berganti antara mode masuk dan daftar — pola yang umum dan menghemat banyak kode duplikat.

dart
// lib/presentation/screens/layar_auth.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/penyedia_auth.dart';
import '../widgets/pemilih_foto_pengguna.dart';

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

  @override
  State<LayarAuth> createState() => _LayarAuthState();
}

class _LayarAuthState extends State<LayarAuth> {
  final _kunciForm = GlobalKey<FormState>();

  var _modeMasuk = true;
  var _email = '';
  var _sandi = '';
  var _nama = '';
  File? _fotoTerpilih;
  var _sandiTersembunyi = true;

  Future<void> _kirim() async {
    final valid = _kunciForm.currentState!.validate();

    // Saat mendaftar, foto wajib dipilih
    if (!valid || (!_modeMasuk && _fotoTerpilih == null)) {
      if (!_modeMasuk && _fotoTerpilih == null) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Pilih foto profil terlebih dahulu.')),
        );
      }
      return;
    }

    _kunciForm.currentState!.save();

    final penyedia = context.read<PenyediaAuth>();

    final berhasil = _modeMasuk
        ? await penyedia.masuk(_email, _sandi)
        : await penyedia.daftar(
            email: _email,
            sandi: _sandi,
            nama: _nama,
            foto: _fotoTerpilih!,
          );

    // Widget mungkin sudah dilepas selama menunggu
    if (!mounted) return;

    if (!berhasil && penyedia.pesanKesalahan != null) {
      ScaffoldMessenger.of(context)
        ..clearSnackBars()
        ..showSnackBar(
          SnackBar(
            content: Text(penyedia.pesanKesalahan!),
            backgroundColor: Theme.of(context).colorScheme.error,
          ),
        );
    }
    // Kalau berhasil, GerbangAuth otomatis mengganti layarnya —
    // tidak perlu Navigator di sini.
  }

  @override
  Widget build(BuildContext context) {
    final skema = Theme.of(context).colorScheme;
    final sedangProses = context.select<PenyediaAuth, bool>(
      (p) => p.sedangProses,
    );

    return Scaffold(
      backgroundColor: skema.primary,
      body: Center(
        child: SingleChildScrollView(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Container(
                margin: const EdgeInsets.only(
                  top: 30,
                  bottom: 20,
                  left: 20,
                  right: 20,
                ),
                width: 180,
                child: Image.asset('assets/gambar/logo.png'),
              ),

              Card(
                margin: const EdgeInsets.all(20),
                child: SingleChildScrollView(
                  child: Padding(
                    padding: const EdgeInsets.all(16),
                    child: Form(
                      key: _kunciForm,
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          // Foto profil hanya saat mendaftar
                          if (!_modeMasuk)
                            PemilihFotoPengguna(
                              saatFotoDipilih: (foto) => _fotoTerpilih = foto,
                            ),

                          if (!_modeMasuk)
                            TextFormField(
                              decoration: const InputDecoration(
                                labelText: 'Nama lengkap',
                                prefixIcon: Icon(Icons.person_outline),
                              ),
                              enableSuggestions: false,
                              textCapitalization: TextCapitalization.words,
                              validator: (nilai) {
                                if (nilai == null || nilai.trim().length < 3) {
                                  return 'Nama minimal 3 karakter.';
                                }
                                return null;
                              },
                              onSaved: (nilai) => _nama = nilai!,
                            ),

                          TextFormField(
                            decoration: const InputDecoration(
                              labelText: 'Alamat email',
                              prefixIcon: Icon(Icons.email_outlined),
                            ),
                            keyboardType: TextInputType.emailAddress,
                            autocorrect: false,
                            textCapitalization: TextCapitalization.none,
                            validator: (nilai) {
                              if (nilai == null ||
                                  nilai.trim().isEmpty ||
                                  !nilai.contains('@') ||
                                  !nilai.contains('.')) {
                                return 'Masukkan alamat email yang valid.';
                              }
                              return null;
                            },
                            onSaved: (nilai) => _email = nilai!,
                          ),

                          TextFormField(
                            decoration: InputDecoration(
                              labelText: 'Kata sandi',
                              prefixIcon: const Icon(Icons.lock_outline),
                              suffixIcon: IconButton(
                                icon: Icon(
                                  _sandiTersembunyi
                                      ? Icons.visibility_off
                                      : Icons.visibility,
                                ),
                                onPressed: () => setState(
                                  () => _sandiTersembunyi = !_sandiTersembunyi,
                                ),
                              ),
                            ),
                            obscureText: _sandiTersembunyi,
                            validator: (nilai) {
                              if (nilai == null || nilai.trim().length < 6) {
                                return 'Kata sandi minimal 6 karakter.';
                              }
                              return null;
                            },
                            onSaved: (nilai) => _sandi = nilai!,
                          ),

                          const SizedBox(height: 20),

                          if (sedangProses)
                            const CircularProgressIndicator()
                          else ...[
                            SizedBox(
                              width: double.infinity,
                              child: ElevatedButton(
                                onPressed: _kirim,
                                style: ElevatedButton.styleFrom(
                                  backgroundColor: skema.primaryContainer,
                                ),
                                child: Text(_modeMasuk ? 'Masuk' : 'Daftar'),
                              ),
                            ),
                            TextButton(
                              onPressed: () => setState(() {
                                _modeMasuk = !_modeMasuk;
                              }),
                              child: Text(
                                _modeMasuk
                                    ? 'Belum punya akun? Daftar'
                                    : 'Sudah punya akun? Masuk',
                              ),
                            ),
                            if (_modeMasuk)
                              TextButton(
                                onPressed: () => Navigator.of(context).push(
                                  MaterialPageRoute(
                                    builder: (_) => const LayarLupaSandi(),
                                  ),
                                ),
                                child: const Text('Lupa kata sandi?'),
                              ),

                            const Divider(height: 24),

                            SizedBox(
                              width: double.infinity,
                              child: OutlinedButton.icon(
                                onPressed: () =>
                                    context.read<PenyediaAuth>().masukGoogle(),
                                icon: Image.asset(
                                  'assets/ikon/google.png',
                                  height: 20,
                                ),
                                label: const Text('Masuk dengan Google'),
                              ),
                            ),
                          ],
                        ],
                      ),
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Perhatikan bahwa layar ini tidak melakukan navigasi setelah berhasil masuk. Ini penerapan pola navigasi reaktif dari Bab 23 — struktur widget yang mengikuti state, bukan Navigator.push manual.

Pemilih foto profil

dart
// lib/presentation/widgets/pemilih_foto_pengguna.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class PemilihFotoPengguna extends StatefulWidget {
  const PemilihFotoPengguna({super.key, required this.saatFotoDipilih});

  final void Function(File foto) saatFotoDipilih;

  @override
  State<PemilihFotoPengguna> createState() => _PemilihFotoPenggunaState();
}

class _PemilihFotoPenggunaState extends State<PemilihFotoPengguna> {
  File? _fotoTerpilih;

  Future<void> _ambilFoto() async {
    final berkas = await ImagePicker().pickImage(
      source: ImageSource.camera,
      imageQuality: 50,   // kompresi kuat — ini hanya avatar kecil
      maxWidth: 150,
    );

    if (berkas == null) return;

    setState(() => _fotoTerpilih = File(berkas.path));
    widget.saatFotoDipilih(_fotoTerpilih!);
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CircleAvatar(
          radius: 40,
          backgroundColor: Colors.grey.shade300,
          foregroundImage:
              _fotoTerpilih != null ? FileImage(_fotoTerpilih!) : null,
          child: _fotoTerpilih == null
              ? const Icon(Icons.person, size: 40, color: Colors.white)
              : null,
        ),
        TextButton.icon(
          onPressed: _ambilFoto,
          icon: const Icon(Icons.image),
          label: const Text('Tambah Foto'),
        ),
      ],
    );
  }
}

maxWidth: 150 dan imageQuality: 50 di sini disengaja: foto profil ditampilkan kecil, jadi mengunggah gambar 4 megapiksel hanya membuang kuota Storage dan memperlambat pemuatan. Prinsip dari Bab 16.

Penyedia autentikasi

dart
// lib/providers/penyedia_auth.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart' as fb;
import '../data/models/pengguna.dart';
import '../data/repositories/auth_repository.dart';
import '../data/services/layanan_auth.dart';

enum StatusAuth { memeriksa, masuk, keluar }

class PenyediaAuth extends ChangeNotifier {
  PenyediaAuth(this._repo) {
    _langganan = _repo.perubahanStatus.listen(_tanganiPerubahan);
  }

  final AuthRepository _repo;
  StreamSubscription<fb.User?>? _langganan;

  StatusAuth _status = StatusAuth.memeriksa;
  Pengguna? _profil;
  bool _sedangProses = false;
  String? _pesanKesalahan;

  StatusAuth get status => _status;
  Pengguna? get profil => _profil;
  bool get sedangProses => _sedangProses;
  String? get pesanKesalahan => _pesanKesalahan;

  Future<void> _tanganiPerubahan(fb.User? user) async {
    if (user == null) {
      _profil = null;
      _status = StatusAuth.keluar;
    } else {
      try {
        _profil = await _repo.ambilProfil(user.uid);
        _status = StatusAuth.masuk;
      } catch (_) {
        // Akun Auth ada tetapi profil Firestore hilang — anggap belum masuk
        await _repo.keluar();
        _profil = null;
        _status = StatusAuth.keluar;
      }
    }
    notifyListeners();
  }

  Future<bool> masuk(String email, String sandi) async {
    return _jalankan(() => _repo.masuk(email: email, sandi: sandi));
  }

  Future<bool> daftar({
    required String email,
    required String sandi,
    required String nama,
    required File foto,
  }) async {
    return _jalankan(
      () => _repo.daftar(
        email: email,
        sandi: sandi,
        nama: nama,
        foto: foto,
      ),
    );
  }

  Future<bool> masukGoogle() {
    return _jalankan(_repo.masukDenganGoogle);
  }

  Future<bool> kirimResetSandi(String email) async {
    return _jalankan(() => _repo.kirimTautanResetSandi(email));
  }

  Future<void> keluar() async {
    await _repo.keluar();
    // _tanganiPerubahan dipanggil otomatis oleh stream
  }

  /// Pembungkus bersama: kelola status proses & kesalahan di satu tempat.
  Future<bool> _jalankan(Future<void> Function() aksi) async {
    _sedangProses = true;
    _pesanKesalahan = null;
    notifyListeners();

    try {
      await aksi();
      return true;
    } on KesalahanAuth catch (e) {
      // Pembatalan oleh pengguna bukan kesalahan yang perlu ditampilkan
      if (e.kode != 'dibatalkan') _pesanKesalahan = e.pesan;
      return false;
    } catch (_) {
      _pesanKesalahan = 'Terjadi kesalahan tak terduga.';
      return false;
    } finally {
      _sedangProses = false;
      notifyListeners();
    }
  }

  @override
  void dispose() {
    _langganan?.cancel();
    super.dispose();
  }
}

Fungsi _jalankan menghilangkan duplikasi try/catch/finally di setiap metode — pola yang sangat layak dibiasakan ketika beberapa aksi punya alur penanganan yang sama.

Gerbang autentikasi

Inilah yang menentukan layar mana yang tampil, tanpa satu pun Navigator.push.

dart
// lib/presentation/screens/gerbang_auth.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/penyedia_auth.dart';
import 'layar_auth.dart';
import 'layar_beranda.dart';
import 'layar_splash.dart';

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

  @override
  Widget build(BuildContext context) {
    final status = context.select<PenyediaAuth, StatusAuth>((p) => p.status);

    return switch (status) {
      StatusAuth.memeriksa => const LayarSplash(),
      StatusAuth.masuk => const LayarBeranda(),
      StatusAuth.keluar => const LayarAuth(),
    };
  }
}

Alurnya:

Keunggulan pola ini: ketika pengguna keluar, seluruh tumpukan navigasi hilang karena widget-nya diganti. Tidak mungkin ada bug "sudah keluar tetapi masih bisa menekan tombol kembali ke beranda".

Layar splash

dart
// lib/presentation/screens/layar_splash.dart
import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Theme.of(context).colorScheme.primary,
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Image.asset('assets/gambar/logo.png', width: 140),
            const SizedBox(height: 32),
            const CircularProgressIndicator(color: Colors.white),
            const SizedBox(height: 16),
            const Text(
              'Memuat...',
              style: TextStyle(color: Colors.white, fontSize: 16),
            ),
          ],
        ),
      ),
    );
  }
}

Layar ini terlihat sekejap saat aplikasi dibuka, ketika Firebase memulihkan sesi dari penyimpanan lokal. Tanpanya, pengguna yang sudah masuk akan melihat layar login berkedip sesaat sebelum diarahkan ke beranda — pengalaman yang terasa tidak rapi.

Lupa kata sandi

dart
// lib/presentation/screens/layar_lupa_sandi.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/penyedia_auth.dart';

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

  @override
  State<LayarLupaSandi> createState() => _LayarLupaSandiState();
}

class _LayarLupaSandiState extends State<LayarLupaSandi> {
  final _kunciForm = GlobalKey<FormState>();
  final _pengendaliEmail = TextEditingController();
  var _terkirim = false;

  @override
  void dispose() {
    _pengendaliEmail.dispose();
    super.dispose();
  }

  Future<void> _kirim() async {
    if (!_kunciForm.currentState!.validate()) return;

    final berhasil = await context
        .read<PenyediaAuth>()
        .kirimResetSandi(_pengendaliEmail.text);

    if (!mounted) return;

    if (berhasil) {
      setState(() => _terkirim = true);
    } else {
      final pesan = context.read<PenyediaAuth>().pesanKesalahan;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(pesan ?? 'Gagal mengirim email.')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final sedangProses =
        context.select<PenyediaAuth, bool>((p) => p.sedangProses);

    return Scaffold(
      appBar: AppBar(title: const Text('Lupa Kata Sandi')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: _terkirim
            ? Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(
                    Icons.mark_email_read_outlined,
                    size: 64,
                    color: Colors.green,
                  ),
                  const SizedBox(height: 16),
                  const Text(
                    'Tautan reset sudah dikirim',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  Text(
                    'Periksa kotak masuk ${_pengendaliEmail.text}. '
                    'Jangan lupa cek folder spam.',
                    textAlign: TextAlign.center,
                  ),
                  const SizedBox(height: 24),
                  ElevatedButton(
                    onPressed: () => Navigator.of(context).pop(),
                    child: const Text('Kembali ke Login'),
                  ),
                ],
              )
            : Form(
                key: _kunciForm,
                child: Column(
                  children: [
                    const Text(
                      'Masukkan email akunmu. Kami akan mengirim tautan '
                      'untuk membuat kata sandi baru.',
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 24),
                    TextFormField(
                      controller: _pengendaliEmail,
                      keyboardType: TextInputType.emailAddress,
                      autocorrect: false,
                      decoration: const InputDecoration(
                        labelText: 'Alamat email',
                        prefixIcon: Icon(Icons.email_outlined),
                        border: OutlineInputBorder(),
                      ),
                      validator: (nilai) {
                        if (nilai == null ||
                            !nilai.contains('@') ||
                            !nilai.contains('.')) {
                          return 'Masukkan email yang valid.';
                        }
                        return null;
                      },
                    ),
                    const SizedBox(height: 24),
                    SizedBox(
                      width: double.infinity,
                      child: ElevatedButton(
                        onPressed: sedangProses ? null : _kirim,
                        child: sedangProses
                            ? const SizedBox(
                                width: 20,
                                height: 20,
                                child: CircularProgressIndicator(
                                  strokeWidth: 2,
                                ),
                              )
                            : const Text('Kirim Tautan Reset'),
                      ),
                    ),
                  ],
                ),
              ),
      ),
    );
  }
}

Jangan bocorkan email mana yang terdaftar

Firebase sengaja tidak memberi tahu apakah email yang dimasukkan terdaftar atau tidak pada sendPasswordResetEmail. Ini disengaja demi keamanan — kalau aplikasimu menampilkan "email tidak terdaftar", penyerang bisa memakainya untuk mengumpulkan daftar email yang valid.

Tampilkan pesan yang sama ("tautan sudah dikirim, periksa kotak masukmu") apa pun hasilnya.

Merangkai di main.dart

dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:provider/provider.dart';
import 'firebase_options.dart';
import 'data/services/layanan_auth.dart';
import 'data/services/layanan_storage.dart';
import 'data/repositories/auth_repository.dart';
import 'providers/penyedia_auth.dart';
import 'presentation/screens/gerbang_auth.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const AplikasiSaya());
}

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

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<LayananAuth>(create: (_) => LayananAuth()),
        Provider<LayananStorage>(create: (_) => LayananStorage()),

        Provider<AuthRepository>(
          create: (ctx) => AuthRepository(
            layananAuth: ctx.read<LayananAuth>(),
            layananStorage: ctx.read<LayananStorage>(),
          ),
        ),

        ChangeNotifierProvider<PenyediaAuth>(
          create: (ctx) => PenyediaAuth(ctx.read<AuthRepository>()),
        ),
      ],
      child: MaterialApp(
        title: 'Aplikasi Polling',
        debugShowCheckedModeBanner: false,
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true,
        ),
        home: const GerbangAuth(),
      ),
    );
  }
}

Urutan penyediaannya mencerminkan arah ketergantungan — layanan, lalu repository, lalu penyedia state. Ini prinsip dari Bab 22 yang diterapkan dengan Provider.

Keamanan yang tidak boleh dilupakan

Verifikasi email. Untuk aplikasi yang serius, jangan izinkan akses penuh sebelum email diverifikasi:

dart
await userAuth.sendEmailVerification();

// Periksa sebelum memberi akses
if (!userAuth.emailVerified) {
  // arahkan ke layar "verifikasi emailmu"
}

Perhatikan bahwa emailVerified di-cache — panggil reload() untuk menyegarkannya:

dart
await _auth.currentUser?.reload();
final terverifikasi = _auth.currentUser?.emailVerified ?? false;

Aturan keamanan Firestore. Autentikasi di sisi aplikasi tidak berarti apa-apa tanpa ini — siapa pun bisa memanggil API Firestore langsung.

javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      // Profil bisa dibaca siapa saja yang sudah masuk
      allow read: if request.auth != null;
      // Tetapi hanya pemiliknya yang bisa menulis
      allow write: if request.auth != null && request.auth.uid == userId;
    }
  }
}

Ini dibahas jauh lebih lengkap di Bab 29 — dan itu bab yang tidak boleh kamu lewati.

Latihan Mandiri

Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.

Variasi 1: Setiap Kode Kesalahan — ⭐⭐ · 45–60 menit

Tantangan: Bangun alur autentikasi lengkap untuk satu aplikasi kosong: daftar, masuk, lupa kata sandi, dan keluar. Yang diuji di sini bukan bagian yang berhasil, melainkan setiap kode kesalahan yang bisa dikembalikan Firebase.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Email    [_______________]        │
│  Sandi    [_______________]        │
│    [ Masuk ]    [Lupa Sandi?]      │
│  ⚠ Email sudah terdaftar, coba     │
│    masuk atau reset sandi          │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Reset kata sandi — email terdaftar ataupun tidak — menampilkan pesan yang sama persis:

text
┌─────────────────────────────────┐
│ Jika email terdaftar, tautan     │
│ reset sudah dikirim.             │
└─────────────────────────────────┘

Kriteria selesai:

  • Seluruh pemanggilan SDK dibungkus di kelas layanan, dan minimal lima kode kesalahan Firebase diterjemahkan menjadi kalimat berbahasa Indonesia yang menyarankan tindakan — bukan menampilkan kodenya mentah.
  • Berpindah antara layar login dan beranda memakai gerbang reaktif yang mengawasi authStateChanges(), bukan push di dalam callback tombol.
  • Ada layar splash yang mencegah layar login berkedip sesaat ketika Firebase sedang memulihkan sesi yang tersimpan.
  • Reset kata sandi menampilkan pesan yang sama apa pun hasilnya, dan kamu bisa menjelaskan kenapa itu keputusan keamanan.
  • Setiap pengguna baru otomatis mendapat dokumen profil di Firestore dengan ID yang sama dengan UID-nya.

Petunjuk: Cara termudah mengumpulkan kode kesalahan adalah memicunya dengan sengaja: daftar dengan email yang sudah dipakai, masuk dengan kata sandi salah, daftar dengan kata sandi tiga huruf, masuk dengan email yang belum terdaftar, dan coba masuk dengan metode yang belum kamu aktifkan di konsol. Yang terakhir menghasilkan operation-not-allowed, dan itu satu-satunya yang penyebabnya ada di konsol, bukan di kode. Untuk reset kata sandi, alasan pesannya diseragamkan adalah bahwa pesan berbeda memberi tahu penyerang email mana yang terdaftar di sistemmu. Kalau kamu menambahkan login Google, ingat urutannya saat keluar: signOut milik Google dulu, baru milik Firebase.

Variasi 2: Verifikasi Email Wajib — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun aplikasi kelas daring yang mewajibkan email terverifikasi sebelum pengguna bisa mengakses isinya. Pengguna yang belum terverifikasi mendapat layar khusus dengan tombol kirim ulang, dan aplikasi mendeteksi verifikasi tanpa pengguna perlu keluar dan masuk lagi.

Bayangan alur layar:

text
Belum masuk               ──▶ [Masuk]
Masuk, belum terverifikasi ──▶ [Verifikasi Email]
                                "Cek emailmu" [Kirim Ulang]
Masuk, terverifikasi       ──▶ [Beranda Kelas]

Kriteria selesai:

  • Gerbang autentikasi punya tiga cabang: belum masuk, masuk tapi belum terverifikasi, dan masuk terverifikasi.
  • Tombol kirim ulang punya jeda minimal, sehingga tidak bisa ditekan berulang-ulang.
  • Aplikasi memeriksa ulang status verifikasi ketika kembali ke layar depan, dan pengguna langsung masuk begitu terverifikasi.
  • Aturan Firestore ikut menolak penulisan dari pengguna yang emailnya belum terverifikasi.

Petunjuk: Status verifikasi disimpan di objek pengguna yang sudah ada di memori, jadi ia tidak berubah sendiri setelah pengguna mengeklik tautan di emailnya — kamu harus memuat ulang objek penggunanya. Pengamat siklus hidup aplikasi memberi tahu kapan pengguna kembali dari aplikasi email, dan itulah saat yang tepat memuat ulang. Untuk kriteria terakhir, ingat bahwa aturan keamanan punya akses ke token pengguna, termasuk penanda verifikasinya — menegakkan di kedua sisi adalah yang membedakan pembatasan sungguhan dari sekadar menyembunyikan tombol.

Variasi 3: Beberapa Metode, Satu Akun — ⭐⭐⭐ · 45–60 menit

Tantangan: Dukung masuk dengan email dan dengan Google sekaligus, lalu tangani kasus yang selalu muncul: pengguna yang sudah punya akun email mencoba masuk dengan Google memakai alamat yang sama. Alih-alih menolak, tautkan keduanya menjadi satu akun.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│  Metode Tertaut:                   │
│  ✓ Email/Sandi          [Lepas]    │
│  ✓ Google               [Lepas]    │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Masuk dengan Google memakai email yang sudah terdaftar via Email/Sandi:

text
┌─────────────────────────────────┐
│ Email ini sudah terdaftar dengan │
│ sandi. Masuk dulu, lalu tautkan  │
│ Google dari layar Profil.        │
└─────────────────────────────────┘

Kriteria selesai:

  • Kasus alamat yang sudah dipakai metode lain terdeteksi dan ditangani, bukan ditampilkan sebagai kegagalan.
  • Setelah ditautkan, pengguna bisa masuk dengan metode mana pun dan mendapatkan akun serta data yang sama.
  • Layar profil menampilkan metode apa saja yang tertaut, dengan opsi melepaskan salah satunya.
  • Melepas metode terakhir yang tersisa ditolak dengan penjelasan.

Petunjuk: Kegagalan yang perlu kamu tangkap punya kode yang menyebut kredensial sudah dipakai akun lain, dan ia membawa informasi metode apa yang sudah terdaftar untuk alamat itu. Alurnya: minta pengguna masuk dengan metode lamanya dulu, lalu tautkan kredensial barunya ke akun yang sudah masuk. Kriteria terakhir penting karena melepas metode terakhir akan mengunci pengguna keluar dari akunnya sendiri secara permanen.

Variasi 4: Profil dan Foto — ⭐⭐⭐ · 45–60 menit

Tantangan: Bangun layar profil lengkap: nama tampilan, foto profil dari kamera atau galeri, nomor telepon, dan preferensi. Foto lama harus terhapus dari Storage ketika diganti, dan perubahan harus terlihat di seluruh aplikasi seketika.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│          ╭─────────╮               │
│          │ ▓▓▓▓▓▓▓ │  [Ganti Foto]│
│          ╰─────────╯               │
│  Nama     [Budi Santoso_____]      │
│  Telepon  [0812-xxx-xxx_____]      │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Ganti foto → foto lama otomatis terhapus dari Storage (tidak menumpuk jadi berkas yatim), dan nama tampilan tersinkron ke Firebase Auth dan Firestore sekaligus.

Kriteria selesai:

  • Foto diperkecil sebelum diunggah, dan jalurnya di Storage memakai UID pengguna sehingga mudah diatur aturan keamanannya.
  • Mengganti foto menghapus berkas lama — tidak ada berkas yatim yang menumpuk.
  • Nama tampilan disimpan di Firebase Auth dan dokumen Firestore, dan keduanya selalu sinkron.
  • Mengunggah yang gagal di tengah tidak meninggalkan profil dengan URL foto yang menunjuk berkas tidak lengkap.

Petunjuk: Menyimpan nama di dua tempat terasa mubazir sampai kamu menyadari alasannya: Firebase Auth memberi akses cepat tanpa membaca Firestore, sementara Firestore memungkinkan kamu menampilkan nama penulis pada dokumen orang lain. Kuncinya adalah menetapkan satu sebagai sumber kebenaran dan memperbarui yang lain dari sana. Untuk kriteria terakhir, unggah dulu sampai selesai, baru perbarui URL di dokumen — urutan terbalik meninggalkan jendela waktu di mana profil menunjuk berkas yang belum ada.

Variasi 5: Sesi dan Keamanan — ⭐⭐⭐⭐ · 60–90 menit

Tantangan: Perkuat aplikasi autentikasi yang sudah kamu buat: minta autentikasi ulang sebelum operasi sensitif (ganti email, ganti kata sandi, hapus akun), tangani token yang kedaluwarsa, dan pastikan menghapus akun juga membersihkan seluruh data penggunanya.

Bayangan tampilan:

text
╭───────────────────────────────────╮
│ 9:41                      ▂▄▆ 🔋   │
│         [ Hapus Akun ]             │
│              ▬▬▬▬                  │
╰───────────────────────────────────╯

Tap "Hapus Akun" → diminta autentikasi ulang dulu sebelum operasi sensitif dijalankan:

text
┌─────────────────────────────────┐
│ Masukkan sandi untuk melanjutkan │
│ Sandi  [_______________]         │
│              [ Konfirmasi ]      │
└─────────────────────────────────┘

Kriteria selesai:

  • Operasi sensitif meminta kata sandi lagi, dan kegagalan autentikasi ulang ditangani dengan pesan yang jelas.
  • Menghapus akun menghapus dokumen profil, seluruh berkas Storage miliknya, dan token FCM-nya.
  • Pengguna yang akunnya dinonaktifkan dari konsol langsung terlempar keluar saat aplikasi memeriksa ulang.
  • Tidak ada satu pun data pengguna yang tersisa di perangkat setelah keluar akun.

Petunjuk: Firebase menolak operasi sensitif kalau sesi pengguna sudah terlalu lama, dengan kode yang menyebut butuh login terbaru — dan menangani itu adalah setengah dari latihan ini. Untuk penghapusan menyeluruh, urutannya penting: hapus data pendukung sebelum menghapus akunnya, karena setelah akun hilang kamu kehilangan izin untuk menyentuh dokumen miliknya. Di aplikasi sungguhan, pekerjaan ini biasanya dipindahkan ke Cloud Function supaya tetap berjalan meski aplikasi ditutup di tengah proses.

Ikhtisar

  • Aktifkan metode login di Firebase Console → Authentication → Sign-in method sebelum menulis kode, atau kamu mendapat operation-not-allowed.
  • Bungkus SDK di kelas layanan. Terjemahkan kode kesalahan Firebase menjadi pesan Bahasa Indonesia yang bisa ditindaklanjuti pengguna.
  • Panggil _google.signOut() sebelum _auth.signOut() — kalau tidak, akun Google tetap terikat dan login berikutnya melewati pemilihan akun.
  • Firebase Auth hanya menyimpan email, nama, dan foto. Data profil lain butuh dokumen Firestore dengan ID sama dengan UID.
  • Timestamp Firestore harus dikonversi ke DateTime saat dibaca, dan sebaliknya saat ditulis.
  • authStateChanges() adalah sumber kebenaran status login. Dengarkan sekali di penyedia state, jangan periksa manual di banyak tempat.
  • Navigasi reaktif (GerbangAuth dengan switch pada status) menghapus seluruh tumpukan navigasi saat keluar — tidak mungkin ada bug "kembali ke beranda setelah logout".
  • Layar splash mencegah layar login berkedip saat Firebase memulihkan sesi.
  • Perkecil foto profil (maxWidth, imageQuality) sebelum diunggah.
  • Fungsi pembungkus seperti _jalankan menghilangkan duplikasi try/catch/finally di setiap aksi.
  • Untuk reset kata sandi, tampilkan pesan yang sama apa pun hasilnya — jangan bocorkan email mana yang terdaftar.
  • emailVerified di-cache; panggil reload() untuk menyegarkannya.
  • Autentikasi di aplikasi tidak berarti apa-apa tanpa Security Rules.

Berikutnya: Bab 29 — Cloud Firestore & Storage.

Transkrip asli

Disintesis dari 4_flutter_ai-chatbot-n-firebase/3_firebase-authentication-basics.md (9 video: setup, form sign-in & sign-up, fungsi Firebase, loader, Google Sign-In, forget password) dan 1_flutter_core/13_push-notification.md (video 3–18: authentication screen, validasi, signup, login, splash, logout, image upload). Lihat PDF Firebase & AI dan PDF Flutter Core.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.