Tampilan
Bab 29 — Cloud Firestore & Storage
Firestore adalah basis data NoSQL yang mengalirkan perubahan secara waktu nyata: satu pengguna memberi suara, dan layar pengguna lain langsung diperbarui tanpa kamu menulis kode pembaruan sama sekali.
Bab ini membahas cara memodelkan data di dalamnya, melakukan operasi CRUD, mengunggah berkas ke Storage, dan — bagian yang paling penting dan paling sering diabaikan — menulis aturan keamanan yang benar.
Model data Firestore
Firestore tidak punya tabel dan baris. Ia punya koleksi dan dokumen.
Koleksi Dokumen Field
─────── ─────── ─────
polls/ ─────────────► {pollId}/ ────────► judul: "..."
│ opsi: [...]
│ totalSuara: 25
│
└── votes/ ──────► {uid}/ ──► indeksOpsi: 0
(subkoleksi) padaWaktu: ...Aturan strukturalnya sederhana dan konsisten:
- Koleksi hanya berisi dokumen.
- Dokumen berisi field, dan boleh berisi subkoleksi.
- Jalur selalu berselang-seling:
koleksi/dokumen/koleksi/dokumen/...
Karena itu polls/abc123 menunjuk ke sebuah dokumen, sedangkan polls/abc123/votes menunjuk ke sebuah koleksi.
Perbedaan penting dari SQL
Di SQL kamu menormalisasi data — memecahnya agar tidak ada duplikasi, lalu menggabungkannya dengan JOIN. Firestore tidak punya JOIN.
Karena itu kamu justru sering menduplikasi data dengan sengaja. Menyimpan namaPemilik di dalam dokumen poll berarti kamu tidak perlu membaca dokumen users setiap kali menampilkan daftar poll — satu pembacaan alih-alih lima puluh.
Ini disebut denormalisasi, dan di Firestore ia adalah praktik yang benar, bukan kemalasan.
Membuat database
Firebase Console → Firestore Database → Create database.
Pilih lokasi yang dekat dengan penggunamu — asia-southeast1 (Singapura) atau asia-southeast2 (Jakarta) untuk pengguna Indonesia. Lokasi tidak bisa diubah setelah dibuat.
Pilih Start in test mode untuk sekarang; kita akan menggantinya dengan aturan yang benar di bagian akhir bab ini.
Model data aplikasi polling
dart
// lib/data/models/poll.dart
import 'package:cloud_firestore/cloud_firestore.dart';
class Poll {
const Poll({
required this.id,
required this.judul,
required this.pemilikId,
required this.namaPemilik,
required this.opsi,
required this.jumlahSuara,
required this.totalSuara,
this.urlGambar,
this.unggulan = false,
this.unggulanSampai,
this.lokasi,
required this.dibuatPada,
});
final String id;
final String judul;
final String pemilikId;
final String namaPemilik; // denormalisasi — hindari baca users
final List<String> opsi;
final List<int> jumlahSuara; // denormalisasi — hindari hitung votes
final int totalSuara;
final String? urlGambar;
final bool unggulan;
final DateTime? unggulanSampai;
final GeoPoint? lokasi;
final DateTime dibuatPada;
factory Poll.dariDokumen(DocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data()!;
return Poll(
id: doc.id,
judul: data['judul'] as String? ?? '',
pemilikId: data['pemilikId'] as String? ?? '',
namaPemilik: data['namaPemilik'] as String? ?? 'Anonim',
opsi: List<String>.from(data['opsi'] as List? ?? const []),
jumlahSuara: List<int>.from(data['jumlahSuara'] as List? ?? const []),
totalSuara: (data['totalSuara'] as num?)?.toInt() ?? 0,
urlGambar: data['urlGambar'] as String?,
unggulan: data['unggulan'] as bool? ?? false,
unggulanSampai: (data['unggulanSampai'] as Timestamp?)?.toDate(),
lokasi: data['lokasi'] as GeoPoint?,
dibuatPada:
(data['dibuatPada'] as Timestamp?)?.toDate() ?? DateTime.now(),
);
}
Map<String, dynamic> toMap() => {
'judul': judul,
'pemilikId': pemilikId,
'namaPemilik': namaPemilik,
'opsi': opsi,
'jumlahSuara': jumlahSuara,
'totalSuara': totalSuara,
'urlGambar': urlGambar,
'unggulan': unggulan,
'unggulanSampai': unggulanSampai == null
? null
: Timestamp.fromDate(unggulanSampai!),
'lokasi': lokasi,
'dibuatPada': Timestamp.fromDate(dibuatPada),
};
/// Persentase suara untuk satu opsi, 0.0–1.0.
double persentase(int indeks) {
if (totalSuara == 0) return 0;
return jumlahSuara[indeks] / totalSuara;
}
bool get masihUnggulan =>
unggulan &&
unggulanSampai != null &&
unggulanSampai!.isAfter(DateTime.now());
}Perhatikan bahwa setiap pembacaan punya nilai cadangan. Dokumen Firestore bisa saja kekurangan field — karena ditulis oleh versi aplikasi lama, atau diedit manual di console. Tanpa ?? ..., aplikasi crash pada dokumen yang tidak sempurna.
GeoPoint adalah tipe bawaan Firestore untuk koordinat, dipakai di Bab 32.
Operasi CRUD
Membuat dokumen
Ada dua cara, dan pilihannya menentukan siapa yang menentukan ID.
dart
// Firestore membuat ID acak
final ref = await _db.collection('polls').add(poll.toMap());
print(ref.id); // "aBcD1234..."
// Kamu yang menentukan ID
await _db.collection('polls').doc('poll-khusus').set(poll.toMap());Pola yang berguna — buat ID sebelum menulis, sehingga kamu bisa memakainya untuk nama berkas gambar:
dart
final ref = _db.collection('polls').doc(); // hanya membuat referensi
final id = ref.id; // ID sudah tersedia
final urlGambar = await _storage.unggahGambarPoll(id, gambar);
await ref.set({...poll.toMap(), 'urlGambar': urlGambar});Membaca dokumen
dart
// Satu dokumen
final doc = await _db.collection('polls').doc(id).get();
if (doc.exists) {
final poll = Poll.dariDokumen(doc);
}
// Seluruh koleksi
final snapshot = await _db.collection('polls').get();
final daftar = snapshot.docs.map(Poll.dariDokumen).toList();Kueri
dart
// Filter
_db.collection('polls').where('pemilikId', isEqualTo: uid)
// Urutkan
_db.collection('polls').orderBy('dibuatPada', descending: true)
// Batasi jumlah
_db.collection('polls').limit(20)
// Gabungan
_db
.collection('polls')
.where('unggulan', isEqualTo: true)
.orderBy('dibuatPada', descending: true)
.limit(10)Operator where yang tersedia:
dart
.where('field', isEqualTo: nilai)
.where('field', isNotEqualTo: nilai)
.where('field', isGreaterThan: nilai)
.where('field', isGreaterThanOrEqualTo: nilai)
.where('field', isLessThan: nilai)
.where('field', arrayContains: nilai) // untuk field bertipe array
.where('field', arrayContainsAny: [a, b]) // maksimal 30 nilai
.where('field', whereIn: [a, b, c]) // maksimal 30 nilai
.where('field', isNull: true)Keterbatasan kueri Firestore
Ini yang paling sering mengejutkan pendatang dari SQL:
- Tidak ada
ORantar field berbeda (kecuali lewatFilter.oryang baru). Kamu tidak bisa menulis "judul mengandung X atau deskripsi mengandung X". - Tidak ada pencarian teks penuh.
LIKE '%kata%'tidak ada. Untuk itu kamu butuh layanan seperti Algolia atau Typesense. - Ketidaksamaan hanya pada satu field. Kamu tidak bisa memfilter
harga > 100danstok > 0sekaligus. orderBywajib pada field yang sama dengan filter ketidaksamaan pertama.
Kalau kueri gagal, Firestore biasanya memberi pesan dengan tautan langsung untuk membuat indeks yang dibutuhkan. Klik tautannya — itu cara tercepat.
Pencarian teks sederhana
Karena tidak ada pencarian teks penuh, trik yang umum dipakai untuk pencarian awalan:
dart
Query<Map<String, dynamic>> cariJudul(String kueri) {
return _db
.collection('polls')
.where('judul', isGreaterThanOrEqualTo: kueri)
.where('judul', isLessThanOrEqualTo: '$kueri\uf8ff');
}\uf8ff adalah kode karakter Unicode dari area penggunaan khusus (Private Use Area) yang nilainya sangat tinggi — lebih tinggi daripada huruf, angka, atau tanda baca apa pun yang lazim dipakai. Menambahkannya di akhir batas atas rentang membuat kueri mencakup semua kemungkinan string yang diawali kueri, sebanyak apa pun karakter tambahan setelahnya.
Karena karakter ini memang tidak dirancang untuk tampil sebagai simbol apa pun — bahkan di font yang mendukungnya, ia biasanya hanya muncul sebagai kotak kosong — selalu tulis sebagai escape \uf8ff di dalam kode, seperti contoh di atas, jangan sebagai karakter mentah yang disalin-tempel.
Trik ini hanya bekerja untuk awalan, bukan kata di tengah — tetapi sering sudah cukup.
Memperbarui
dart
// Perbarui field tertentu saja
await _db.collection('polls').doc(id).update({
'judul': 'Judul baru',
'diubahPada': FieldValue.serverTimestamp(),
});
// Timpa seluruh dokumen
await _db.collection('polls').doc(id).set(poll.toMap());
// Gabungkan — buat kalau belum ada, perbarui kalau sudah
await _db.collection('polls').doc(id).set(
poll.toMap(),
SetOptions(merge: true),
);FieldValue menyediakan operasi khusus yang dijalankan di server:
dart
// Waktu server — tidak bergantung jam perangkat pengguna
'dibuatPada': FieldValue.serverTimestamp()
// Tambah/kurang secara atomik — aman dari kondisi balapan
'totalSuara': FieldValue.increment(1)
'stok': FieldValue.increment(-1)
// Operasi array
'tag': FieldValue.arrayUnion(['flutter']) // tambah kalau belum ada
'tag': FieldValue.arrayRemove(['lama']) // hapus
// Hapus field
'fieldLama': FieldValue.delete()FieldValue.increment sangat penting. Bandingkan:
dart
// ❌ Kondisi balapan — dua pengguna memberi suara bersamaan,
// satu suara hilang
final doc = await ref.get();
final total = doc.data()!['totalSuara'] as int;
await ref.update({'totalSuara': total + 1});
// ✅ Atomik — server yang menambahkan, tidak mungkin hilang
await ref.update({'totalSuara': FieldValue.increment(1)});Menghapus
dart
await _db.collection('polls').doc(id).delete();Menghapus dokumen tidak menghapus subkoleksinya
Ini jebakan yang sangat mahal. Menghapus polls/abc123 tidak menghapus polls/abc123/votes — dokumen-dokumen itu menjadi yatim, tetap memakan penyimpanan, dan tidak terlihat di console.
Kamu harus menghapusnya sendiri, atau lebih baik lewat Cloud Function:
dart
Future<void> hapusPollBesertaSuara(String id) async {
final ref = _db.collection('polls').doc(id);
final suara = await ref.collection('votes').get();
final batch = _db.batch();
for (final doc in suara.docs) {
batch.delete(doc.reference);
}
batch.delete(ref);
await batch.commit();
}Waktu nyata dengan snapshots
Inilah keunggulan utama Firestore. Alih-alih get() yang membaca sekali, snapshots() mengembalikan Stream yang mengeluarkan data baru setiap kali ada perubahan.
dart
Stream<List<Poll>> aliranPoll() {
return _db
.collection('polls')
.orderBy('dibuatPada', descending: true)
.limit(50)
.snapshots()
.map((snapshot) => snapshot.docs.map(Poll.dariDokumen).toList());
}Memakainya dengan StreamBuilder:
dart
StreamBuilder<List<Poll>>(
stream: repositori.aliranPoll(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Gagal memuat: ${snapshot.error}'));
}
final daftar = snapshot.data ?? [];
if (daftar.isEmpty) {
return const Center(child: Text('Belum ada polling.'));
}
return ListView.builder(
itemCount: daftar.length,
itemBuilder: (ctx, i) => KartuPoll(poll: daftar[i]),
);
},
)Atau dengan StreamProvider Riverpod dari Bab 18:
dart
final pollProvider = StreamProvider<List<Poll>>((ref) {
return ref.watch(pollRepositoryProvider).aliranPoll();
});snapshots() memakan kuota baca
Setiap dokumen yang dikirimkan stream dihitung sebagai satu pembacaan. Sebuah snapshots() pada koleksi berisi 1.000 dokumen akan langsung memakai 1.000 pembacaan, lalu bertambah setiap kali ada perubahan.
Selalu pasang limit(). Dan matikan listener yang tidak dipakai — kalau kamu memakai StreamBuilder, Flutter mengurusnya otomatis; kalau kamu listen() manual, batalkan di dispose() atau close().
Transaksi dan batch
Batch: banyak tulisan sekaligus
dart
final batch = _db.batch();
batch.set(_db.collection('polls').doc(), poll.toMap());
batch.update(_db.collection('users').doc(uid), {
'jumlahPoll': FieldValue.increment(1),
});
batch.delete(_db.collection('drafts').doc(draftId));
await batch.commit(); // semua berhasil, atau semua gagalBatch bersifat atomik: kalau satu operasi gagal, tidak ada yang tertulis. Batasnya 500 operasi per batch.
Transaksi: baca lalu tulis
Ketika tulisanmu bergantung pada nilai yang dibaca, kamu butuh transaksi.
dart
Future<void> beriSuara({
required String pollId,
required String uid,
required int indeksOpsi,
}) async {
final refPoll = _db.collection('polls').doc(pollId);
final refSuara = refPoll.collection('votes').doc(uid);
await _db.runTransaction((transaksi) async {
// 1. SEMUA pembacaan harus dilakukan lebih dulu
final docSuara = await transaksi.get(refSuara);
final docPoll = await transaksi.get(refPoll);
if (!docPoll.exists) {
throw Exception('Polling tidak ditemukan.');
}
if (docSuara.exists) {
throw Exception('Kamu sudah memberi suara pada polling ini.');
}
// 2. Baru lakukan penulisan
final jumlah = List<int>.from(docPoll.data()!['jumlahSuara'] as List);
jumlah[indeksOpsi] += 1;
transaksi.set(refSuara, {
'indeksOpsi': indeksOpsi,
'padaWaktu': FieldValue.serverTimestamp(),
});
transaksi.update(refPoll, {
'jumlahSuara': jumlah,
'totalSuara': FieldValue.increment(1),
});
});
}Semua get() sebelum semua set()
Di dalam transaksi Firestore, kamu wajib menyelesaikan seluruh pembacaan sebelum melakukan penulisan pertama. Menyelipkan get() setelah set() akan menyebabkan error.
Firestore juga bisa mengulang transaksi secara otomatis kalau data berubah di tengah jalan — jadi fungsi di dalamnya harus bebas efek samping. Jangan memanggil API eksternal atau mengubah state aplikasi di dalamnya.
Firebase Storage
Firestore untuk data terstruktur; Storage untuk berkas — gambar, video, PDF.
dart
// lib/data/services/layanan_storage.dart
import 'dart:io';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:path/path.dart' as p;
class KesalahanStorage implements Exception {
KesalahanStorage(this.pesan);
final String pesan;
@override
String toString() => pesan;
}
class LayananStorage {
LayananStorage({FirebaseStorage? storage})
: _storage = storage ?? FirebaseStorage.instance;
final FirebaseStorage _storage;
Future<String> unggahFotoProfil(String uid, File berkas) async {
return _unggah('user_images/$uid.jpg', berkas);
}
Future<String> unggahGambarPoll(String pollId, File berkas) async {
final ekstensi = p.extension(berkas.path);
return _unggah('poll_images/$pollId$ekstensi', berkas);
}
Future<String> _unggah(String jalur, File berkas) async {
try {
final ref = _storage.ref().child(jalur);
final tugas = await ref.putFile(
berkas,
SettableMetadata(contentType: _tipeKonten(berkas.path)),
);
return await tugas.ref.getDownloadURL();
} on FirebaseException catch (e) {
throw KesalahanStorage(
switch (e.code) {
'unauthorized' => 'Kamu tidak punya izin mengunggah berkas.',
'canceled' => 'Unggahan dibatalkan.',
'quota-exceeded' => 'Kuota penyimpanan habis.',
_ => 'Gagal mengunggah berkas.',
},
);
}
}
/// Unggah dengan pelaporan progres.
Stream<double> unggahDenganProgres(String jalur, File berkas) {
final ref = _storage.ref().child(jalur);
final tugas = ref.putFile(berkas);
return tugas.snapshotEvents.map((peristiwa) {
if (peristiwa.totalBytes == 0) return 0.0;
return peristiwa.bytesTransferred / peristiwa.totalBytes;
});
}
Future<void> hapus(String url) async {
try {
await _storage.refFromURL(url).delete();
} on FirebaseException catch (e) {
// Berkas sudah tidak ada — bukan masalah
if (e.code == 'object-not-found') return;
rethrow;
}
}
String _tipeKonten(String jalur) {
return switch (p.extension(jalur).toLowerCase()) {
'.png' => 'image/png',
'.gif' => 'image/gif',
'.webp' => 'image/webp',
_ => 'image/jpeg',
};
}
}Beberapa keputusan yang layak diperhatikan:
Nama berkas berbasis ID, bukan acak. user_images/$uid.jpg berarti mengunggah foto baru otomatis menimpa yang lama — tidak ada berkas yatim yang menumpuk.
SettableMetadata(contentType: ...). Tanpa ini, berkas diunggah sebagai application/octet-stream, dan peramban akan mengunduhnya alih-alih menampilkannya sebagai gambar.
object-not-found diabaikan saat menghapus. Menghapus sesuatu yang sudah tidak ada bukan kegagalan.
Menampilkan progres unggahan
dart
class _FormPollState extends State<FormPoll> {
double? _progres;
Future<void> _unggah(File gambar) async {
final aliran = _storage.unggahDenganProgres('poll_images/x.jpg', gambar);
await for (final nilai in aliran) {
if (!mounted) return;
setState(() => _progres = nilai);
}
if (!mounted) return;
setState(() => _progres = null);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
if (_progres != null) ...[
LinearProgressIndicator(value: _progres),
const SizedBox(height: 4),
Text('${(_progres! * 100).toInt()}%'),
],
],
);
}
}Aturan keamanan
Bagian ini yang paling penting di seluruh bab.
Aturan keamanan berjalan di server Firebase, dan ia adalah satu-satunya hal yang melindungi datamu. Aplikasi Flutter bisa dibongkar, dan siapa pun bisa memanggil API Firestore langsung dengan kunci yang ada di dalamnya.
Mode uji coba yang kita pilih di awal bab terlihat seperti ini:
javascript
// ⚠️ SIAPA PUN bisa membaca dan menulis apa pun
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.time < timestamp.date(2026, 9, 27);
}
}
}Ini tidak boleh dibiarkan. Ganti dengan aturan yang sesungguhnya.
Aturan Firestore untuk aplikasi polling
javascript
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// --- Fungsi bantu ---
function sudahMasuk() {
return request.auth != null;
}
function adalahPemilik(uid) {
return sudahMasuk() && request.auth.uid == uid;
}
function dataBaru() {
return request.resource.data;
}
function dataLama() {
return resource.data;
}
// --- Profil pengguna ---
match /users/{userId} {
// Siapa pun yang sudah masuk boleh melihat profil
allow read: if sudahMasuk();
// Hanya pemilik yang boleh membuat & mengubah profilnya
allow create: if adalahPemilik(userId)
&& dataBaru().email is string
&& dataBaru().nama is string
&& dataBaru().nama.size() >= 3;
allow update: if adalahPemilik(userId)
// Email tidak boleh diubah lewat Firestore
&& dataBaru().email == dataLama().email;
allow delete: if adalahPemilik(userId);
}
// --- Polling ---
match /polls/{pollId} {
allow read: if sudahMasuk();
allow create: if sudahMasuk()
// Tidak bisa membuat poll atas nama orang lain
&& dataBaru().pemilikId == request.auth.uid
&& dataBaru().judul is string
&& dataBaru().judul.size() > 0
&& dataBaru().judul.size() <= 200
&& dataBaru().opsi is list
&& dataBaru().opsi.size() >= 2
&& dataBaru().opsi.size() <= 10
&& dataBaru().totalSuara == 0
// Tidak bisa langsung menjadikan poll unggulan
&& dataBaru().unggulan == false;
allow update: if
// Pemilik boleh mengubah judul & opsi
(adalahPemilik(dataLama().pemilikId)
&& dataBaru().pemilikId == dataLama().pemilikId)
||
// Pengguna lain hanya boleh menambah hitungan suara
(sudahMasuk()
&& dataBaru().diff(dataLama()).affectedKeys()
.hasOnly(['jumlahSuara', 'totalSuara'])
&& dataBaru().totalSuara == dataLama().totalSuara + 1);
allow delete: if adalahPemilik(dataLama().pemilikId);
// --- Suara ---
match /votes/{userId} {
allow read: if sudahMasuk();
// ID dokumen = UID, jadi satu orang hanya bisa punya satu suara
allow create: if adalahPemilik(userId)
&& dataBaru().indeksOpsi is int
&& dataBaru().indeksOpsi >= 0;
// Suara tidak bisa diubah atau dihapus
allow update, delete: if false;
}
}
// --- Riwayat chat ---
match /chats/{userId}/messages/{messageId} {
// Hanya pemilik yang bisa membaca chatnya sendiri
allow read, create: if adalahPemilik(userId);
allow update, delete: if false;
}
// --- Tolak segalanya yang tidak disebutkan di atas ---
match /{document=**} {
allow read, write: if false;
}
}
}Beberapa teknik yang dipakai di atas layak dijelaskan.
dataBaru().pemilikId == request.auth.uid mencegah seseorang membuat poll atas nama orang lain — validasi yang tidak bisa dilewati dengan membongkar aplikasi.
diff(dataLama()).affectedKeys().hasOnly([...]) membatasi field mana yang boleh diubah. Ini yang memungkinkan pengguna lain menambah hitungan suara tanpa bisa mengubah judul poll.
dataBaru().unggulan == false saat membuat mencegah pengguna menjadikan polnya unggulan tanpa membayar. Perubahan itu hanya boleh dilakukan Cloud Function — dibahas di Bab 36.
Blok terakhir allow read, write: if false adalah jaring pengaman: apa pun yang tidak disebutkan secara eksplisit ditolak.
Aturan Storage
javascript
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
function sudahMasuk() {
return request.auth != null;
}
function gambarValid() {
return request.resource.contentType.matches('image/.*')
&& request.resource.size < 5 * 1024 * 1024; // maksimal 5 MB
}
// Foto profil — hanya pemilik yang bisa mengunggah
match /user_images/{userId} {
allow read: if sudahMasuk();
allow write: if sudahMasuk()
&& request.auth.uid == userId.split('\\.')[0]
&& gambarValid();
}
// Gambar poll
match /poll_images/{berkas} {
allow read: if sudahMasuk();
allow write: if sudahMasuk() && gambarValid();
}
match /{allPaths=**} {
allow read, write: if false;
}
}
}Batas ukuran 5 MB itu penting. Tanpanya, seseorang bisa mengunggah berkas berukuran gigabita dan menghabiskan kuotamu.
Menguji aturan
Firebase Console → Firestore → tab Rules → Rules Playground. Kamu bisa mensimulasikan operasi dengan UID tertentu dan melihat apakah ditolak atau diizinkan — jauh lebih cepat daripada menguji lewat aplikasi.
Untuk pengujian otomatis, ada emulator:
bash
firebase emulators:exec --only firestore "npm test"Daftar periksa sebelum rilis
- Mode uji coba SUDAH diganti dengan aturan sungguhan
- Ada blok penolakan default di akhir
- Setiap koleksi punya aturan eksplisit
- Penulisan memvalidasi pemilik (pemilikId == request.auth.uid)
- Field sensitif (unggulan, peran, saldo) tidak bisa ditulis klien
- Storage membatasi contentType dan ukuran berkas
- Aturan sudah diuji di Rules Playground
Data yang bocor karena aturan mode uji coba yang tertinggal adalah salah satu kesalahan paling umum di aplikasi Firebase — dan salah satu yang paling mahal.
Repository lengkap
dart
// lib/data/repositories/poll_repository.dart
import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../models/poll.dart';
import '../services/layanan_storage.dart';
class PollRepository {
PollRepository({
required LayananStorage storage,
FirebaseFirestore? firestore,
}) : _storage = storage,
_db = firestore ?? FirebaseFirestore.instance;
final LayananStorage _storage;
final FirebaseFirestore _db;
CollectionReference<Map<String, dynamic>> get _koleksi =>
_db.collection('polls');
// --- Aliran waktu nyata ---
Stream<List<Poll>> aliranSemua({int batas = 50}) {
return _koleksi
.orderBy('dibuatPada', descending: true)
.limit(batas)
.snapshots()
.map((s) => s.docs.map(Poll.dariDokumen).toList());
}
Stream<List<Poll>> aliranMilikSaya(String uid) {
return _koleksi
.where('pemilikId', isEqualTo: uid)
.orderBy('dibuatPada', descending: true)
.snapshots()
.map((s) => s.docs.map(Poll.dariDokumen).toList());
}
Stream<List<Poll>> aliranUnggulan() {
return _koleksi
.where('unggulan', isEqualTo: true)
.where('unggulanSampai', isGreaterThan: Timestamp.now())
.orderBy('unggulanSampai', descending: true)
.limit(10)
.snapshots()
.map((s) => s.docs.map(Poll.dariDokumen).toList());
}
Stream<Poll> aliranSatu(String id) {
return _koleksi
.doc(id)
.snapshots()
.where((doc) => doc.exists)
.map(Poll.dariDokumen);
}
// --- Membuat ---
Future<Poll> buat({
required String judul,
required List<String> opsi,
required String pemilikId,
required String namaPemilik,
File? gambar,
GeoPoint? lokasi,
}) async {
// Buat referensi dulu agar ID tersedia untuk nama berkas
final ref = _koleksi.doc();
String? urlGambar;
if (gambar != null) {
urlGambar = await _storage.unggahGambarPoll(ref.id, gambar);
}
final poll = Poll(
id: ref.id,
judul: judul.trim(),
pemilikId: pemilikId,
namaPemilik: namaPemilik,
opsi: opsi,
jumlahSuara: List.filled(opsi.length, 0),
totalSuara: 0,
urlGambar: urlGambar,
lokasi: lokasi,
dibuatPada: DateTime.now(),
);
await ref.set(poll.toMap());
return poll;
}
// --- Memberi suara ---
Future<void> beriSuara({
required String pollId,
required String uid,
required int indeksOpsi,
}) async {
final refPoll = _koleksi.doc(pollId);
final refSuara = refPoll.collection('votes').doc(uid);
await _db.runTransaction((transaksi) async {
final docSuara = await transaksi.get(refSuara);
final docPoll = await transaksi.get(refPoll);
if (!docPoll.exists) {
throw Exception('Polling tidak ditemukan.');
}
if (docSuara.exists) {
throw Exception('Kamu sudah memberi suara pada polling ini.');
}
final jumlah = List<int>.from(docPoll.data()!['jumlahSuara'] as List);
if (indeksOpsi < 0 || indeksOpsi >= jumlah.length) {
throw Exception('Pilihan tidak valid.');
}
jumlah[indeksOpsi] += 1;
transaksi.set(refSuara, {
'indeksOpsi': indeksOpsi,
'padaWaktu': FieldValue.serverTimestamp(),
});
transaksi.update(refPoll, {
'jumlahSuara': jumlah,
'totalSuara': FieldValue.increment(1),
});
});
}
Future<int?> suaraSaya(String pollId, String uid) async {
final doc = await _koleksi.doc(pollId).collection('votes').doc(uid).get();
if (!doc.exists) return null;
return doc.data()!['indeksOpsi'] as int;
}
// --- Memperbarui & menghapus ---
Future<void> perbaruiJudul(String id, String judul) async {
await _koleksi.doc(id).update({
'judul': judul.trim(),
'diubahPada': FieldValue.serverTimestamp(),
});
}
Future<void> hapus(Poll poll) async {
final ref = _koleksi.doc(poll.id);
// Hapus subkoleksi votes secara eksplisit
final suara = await ref.collection('votes').get();
final batch = _db.batch();
for (final doc in suara.docs) {
batch.delete(doc.reference);
}
batch.delete(ref);
await batch.commit();
// Hapus gambar agar tidak menjadi berkas yatim
if (poll.urlGambar != null) {
await _storage.hapus(poll.urlGambar!);
}
}
// --- Pemuatan bertahap ---
Future<List<Poll>> ambilHalaman({
DocumentSnapshot? setelah,
int batas = 20,
}) async {
var kueri = _koleksi.orderBy('dibuatPada', descending: true).limit(batas);
if (setelah != null) {
kueri = kueri.startAfterDocument(setelah);
}
final snapshot = await kueri.get();
return snapshot.docs.map(Poll.dariDokumen).toList();
}
}Dukungan offline
Firestore menyimpan cache secara lokal dan bekerja tanpa internet secara bawaan di Android dan iOS.
dart
// Mengatur ukuran cache
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);Kamu bisa mendeteksi apakah data berasal dari cache:
dart
StreamBuilder<QuerySnapshot>(
stream: _koleksi.snapshots(includeMetadataChanges: true),
builder: (context, snapshot) {
final dariCache = snapshot.data?.metadata.isFromCache ?? false;
return Column(
children: [
if (dariCache)
Container(
width: double.infinity,
color: Colors.orange.shade100,
padding: const EdgeInsets.all(8),
child: const Text(
'Menampilkan data tersimpan. Perubahan akan disinkronkan '
'saat kamu kembali daring.',
textAlign: TextAlign.center,
),
),
// ... daftar
],
);
},
)Tulisan yang dilakukan saat offline akan disimpan dan dikirim otomatis ketika koneksi kembali. Ini berbeda dari pendekatan SQFlite manual di Bab 31, yang memberi kendali lebih tetapi butuh jauh lebih banyak kode.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Ulasan Tempat Makan — ⭐⭐ · 45–60 menit
Tantangan: Buat satu koleksi Firestore berisi ulasan tempat makan: teks, rating, penulis, dan satu foto di Storage. Tampilkan daftarnya secara waktu nyata, dan buat rating rata-rata yang tidak pernah salah hitung meskipun lima orang menulis ulasan pada detik yang sama.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ ⭐ 4.6 (128 ulasan) │
│ ┌─────────────────────────────┐ │
│ │▓▓▓ Budi S. ⭐⭐⭐⭐⭐ │ │
│ │ "Enak banget, porsi besar" │ │
│ │▓▓▓ Citra D. ⭐⭐⭐⭐ │ │
│ │ "Pelayanan ramah" │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯"⭐ 4.6 (128 ulasan)" di atas berasal dari satu dokumen berisi dua angka (jumlahUlasan, totalRating) — bukan hasil membaca dan merata-ratakan 128 dokumen ulasan setiap kali layar dibuka.
Kriteria selesai:
- Setiap
snapshots()memakailimit(). Tidak ada langganan yang mengambil seluruh koleksi. - Jumlah ulasan dan total rating diperbarui dengan
FieldValue.increment(), bukan baca-lalu-tulis. - Waktu pembuatan memakai
FieldValue.serverTimestamp(), danTimestampdikonversi keDateTimesaat dibaca. - Nama penulis didenormalisasi ke dalam dokumen ulasan, sehingga menampilkan daftar tidak memicu satu pembacaan tambahan per baris.
- Aturan keamanan ditulis dan diterapkan: hanya penulis yang bisa mengubah atau menghapus ulasannya sendiri, dan kamu sudah membuktikannya dengan mencoba melanggarnya.
- Menghapus ulasan juga menghapus fotonya dari Storage.
Petunjuk: Kalau kamu tergoda menghitung rata-rata dengan membaca seluruh ulasan lalu membaginya, bayangkan koleksi itu berisi sepuluh ribu dokumen: setiap kali seseorang membuka layar, kamu membayar sepuluh ribu pembacaan. Menyimpan jumlahUlasan dan totalRating sebagai dua angka di dokumen tempat makan mengubahnya menjadi satu pembacaan — dan FieldValue.increment() membuat penambahan itu aman meskipun terjadi bersamaan, karena penambahannya dilakukan di server, bukan di perangkat. Untuk aturan keamanan, cara termudah menguji adalah lewat simulator di konsol Firebase, tetapi menguji dari aplikasi sungguhan dengan dua akun berbeda jauh lebih meyakinkan.
Variasi 2: Katalog Tanaman Bertingkat — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun katalog tanaman hias dengan penyaring jenis, tingkat perawatan, dan pengurutan berdasarkan nama atau tanggal ditambahkan. Tambahkan paginasi yang memuat dua puluh dokumen per halaman.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Jenis:[Kaktus▾] Perawatan:[Mudah▾]│
│ Urutkan: [Nama▾] │
│ ┌─────────────────────────────┐ │
│ │ Item 1 ... Item 20 │ │
│ └─────────────────────────────┘ │
│ [ Muat 20 Berikutnya ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Paginasi memakai kursor dokumen terakhir, bukan melewati sejumlah dokumen dari awal.
- Setiap kombinasi penyaring dan pengurutan yang butuh indeks sudah dibuat indeksnya, dan kamu memakai tautan dari pesan error untuk membuatnya.
- Kamu bisa menyebut satu kombinasi penyaring yang tidak bisa dilakukan Firestore, dan bagaimana kamu menyiasatinya.
- Jumlah pembacaan per pemuatan halaman bisa kamu perkirakan dan sebutkan.
Petunjuk: Melewati dokumen dari awal tetap membaca — dan membayar — seluruh dokumen yang dilewati, sehingga halaman kesepuluh berbiaya sepuluh kali halaman pertama. Kursor berbasis dokumen terakhir membuat setiap halaman berbiaya sama. Untuk kriteria ketiga, batasan yang paling sering menggigit adalah OR antar field berbeda dan ketidakhadiran pencarian teks penuh; siasat yang umum adalah menyimpan field turunan yang sudah digabungkan, atau memindahkan pencarian ke layanan lain.
Variasi 3: Pemesanan Kelas Yoga — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun pemesanan slot kelas dengan kuota terbatas. Ketika dua orang menekan tombol pesan pada saat yang sama untuk slot terakhir, tepat satu harus berhasil dan yang lain mendapat penolakan yang jelas.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Yoga Pagi — 2 dari 3 slot terisi │
│ [ Pesan Slot ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Dua orang menekan "Pesan Slot" bersamaan untuk slot terakhir:
text
Orang A: ✓ "Berhasil dipesan!"
Orang B: ✗ "Maaf, slot baru saja terisi orang lain"Kriteria selesai:
- Pemesanan memakai transaksi, dan seluruh pembacaan di dalamnya selesai sebelum penulisan pertama.
- Fungsi transaksi bebas efek samping, karena ia bisa dijalankan ulang.
- Membatalkan pemesanan mengembalikan kuota, juga secara atomik.
- Kamu sudah mensimulasikan tabrakan dengan menjalankan dua permintaan hampir bersamaan, dan membuktikan kuota tidak pernah minus.
Petunjuk: Kriteria kedua adalah aturan yang mudah dilanggar tanpa sadar: menampilkan snackbar, menulis log, atau menambah penghitung lokal di dalam fungsi transaksi berarti hal itu bisa terjadi dua kali, karena Firestore menjalankan ulang transaksi yang berbenturan. Simpan hasilnya, lalu lakukan efek sampingnya setelah transaksi selesai. Untuk mensimulasikan tabrakan, memanggil dua transaksi tanpa await di antaranya biasanya sudah cukup.
Variasi 4: Berkas dan Unggahan — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun fitur unggah lampiran yang mendukung banyak berkas sekaligus, menampilkan progres per berkas, mengizinkan pembatalan di tengah jalan, dan menolak berkas yang terlalu besar atau bertipe tidak diizinkan — ditegakkan di aturan Storage, bukan hanya di aplikasi.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Laporan.pdf [▓▓▓▓▓░░░] 62% [✗] │
│ Foto1.jpg [▓▓▓▓▓▓▓▓] 100% │
│ Foto2.jpg [▓▓░░░░░░] 20% [✗] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Tap [✗] di tengah unggah "Foto2.jpg" → unggahan benar-benar berhenti, dan tidak ada berkas separuh yang tertinggal di Storage.
Kriteria selesai:
- Progres tiap berkas ditampilkan dan bergerak nyata, bukan indikator berputar.
- Pembatalan benar-benar menghentikan unggahan dan tidak meninggalkan berkas separuh di Storage.
- Aturan Storage membatasi ukuran dan tipe konten, dan kamu sudah membuktikannya dengan mencoba melanggarnya dari kode.
- Struktur jalur di Storage rapi dan bisa diatur izinnya per pengguna.
Petunjuk: Tugas unggah di Storage mengembalikan objek yang memancarkan kemajuan sekaligus menyediakan cara membatalkan dan menjeda — banyak orang hanya menunggu hasilnya dan kehilangan keduanya. Untuk aturan, ingat bahwa metadata ukuran dan tipe konten tersedia di dalam aturan Storage, sehingga pembatasan bisa ditegakkan sebelum satu bita pun tersimpan.
Variasi 5: Aturan Keamanan Berlapis — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Tulis aturan keamanan untuk aplikasi dengan tiga peran — pembaca, penulis, dan moderator — di mana kepemilikan dokumen, peran, dan status verifikasi email semuanya berpengaruh. Buktikan setiap aturan dengan mencoba melanggarnya.
Kriteria selesai:
- Peran disimpan di tempat yang tidak bisa ditulis pengguna sendiri.
- Aturan memakai fungsi bantu supaya tidak ada logika yang disalin berulang.
- Aturan memvalidasi bentuk data yang ditulis, bukan hanya siapa yang menulis — field wajib ada dan bertipe benar.
- Kamu menguji minimal delapan skenario, termasuk yang seharusnya ditolak.
Petunjuk: Kriteria pertama adalah lubang keamanan paling umum di aplikasi Firebase: menyimpan peran di dokumen profil yang boleh ditulis pemiliknya berarti siapa pun bisa mengangkat dirinya menjadi moderator. Simpan di koleksi terpisah yang hanya bisa ditulis server, atau pakai custom claims. Kriteria ketiga sering diabaikan padahal sama pentingnya — tanpa validasi bentuk, pengguna sah bisa menulis dokumen yang merusak aplikasimu untuk semua orang.
Variasi 6: Subkoleksi dan Yatim — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Bangun struktur bertingkat: proyek berisi tugas, tugas berisi komentar. Lalu tangani penghapusan dengan benar di semua tingkat, dan buktikan tidak ada dokumen yang tertinggal tanpa induk.
Kriteria selesai:
- Menghapus proyek menghapus seluruh tugas dan seluruh komentarnya.
- Penghapusan berkelompok memakai
batchdan menghormati batas jumlah operasi per batch. - Kamu bisa menunjukkan di konsol bahwa tidak ada dokumen yatim yang tertinggal setelah penghapusan.
- Kamu bisa menjelaskan kapan struktur bertingkat ini justru sebaiknya diratakan menjadi koleksi terpisah.
Petunjuk: Menghapus dokumen di Firestore tidak menghapus subkoleksinya — dokumen induknya hilang dari daftar, tetapi anak-anaknya tetap ada, tetap memakan ruang, dan tetap bisa diakses lewat jalur langsung. Batch punya batas jumlah operasi, jadi penghapusan besar butuh beberapa batch berurutan. Untuk kriteria terakhir, subkoleksi menyulitkan kueri lintas induk — kalau kamu sering perlu "semua komentar dari semua proyek", koleksi rata dengan field penunjuk biasanya lebih baik.
Ikhtisar
- Firestore memakai koleksi dan dokumen yang berselang-seling. Tidak ada tabel, tidak ada
JOIN. - Denormalisasi disengaja — simpan
namaPemilikdi dokumen poll agar tidak perlu membaca koleksiusersberkali-kali. - Beri nilai cadangan pada setiap pembacaan field, karena dokumen bisa saja tidak lengkap.
FieldValue.increment()bersifat atomik — pakai ini alih-alih baca-lalu- tulis yang rawan kondisi balapan.FieldValue.serverTimestamp()memakai jam server, bukan jam perangkat pengguna yang bisa salah.snapshots()memberi pembaruan waktu nyata, tetapi setiap dokumen dihitung sebagai pembacaan. Selalu pasanglimit().- Firestore tidak punya pencarian teks penuh dan
ORantar field terbatas. Trik\uf8ff(escape Unicode dari area penggunaan khusus) bekerja untuk pencarian awalan. - Kalau kueri butuh indeks, pesan errornya menyertakan tautan langsung untuk membuatnya.
- Menghapus dokumen tidak menghapus subkoleksinya — hapus manual dengan
batch, atau dokumennya menjadi yatim. - Di dalam transaksi, semua
get()harus selesai sebelumset()pertama, dan fungsinya harus bebas efek samping karena bisa diulang. - Untuk Storage, pakai nama berkas berbasis ID agar unggahan baru menimpa yang lama, dan setel
contentTypeagar gambar tampil di peramban. - Aturan keamanan adalah satu-satunya hal yang melindungi datamu. Aplikasi bisa dibongkar; aturan berjalan di server.
- Selalu ada blok penolakan default di akhir aturan.
- Batasi field yang bisa diubah dengan
diff().affectedKeys().hasOnly([...]). - Batasi
contentTypedan ukuran berkas di aturan Storage. - Jangan pernah merilis dengan aturan mode uji coba.
Berikutnya: Bab 30 — Push Notification (FCM).
Transkrip asli
Disintesis dari 4_flutter_ai-chatbot-n-firebase/4_firestore-database-and-storage-crud.md (10 video: home screen & tab, create poll I–III, image upload, edit poll, delete, handle votes & voters tab) dan 1_flutter_core/13_push-notification.md (video 19–25: Firestore setup, mengirim data, chat messages sebagai stream). Lihat PDF Firebase & AI.