Tampilan
Bab 15 — Penyimpanan Lokal
Data yang hanya ada di memori lenyap ketika aplikasi ditutup. Bab ini membahas cara menyimpannya di perangkat — dari preferensi sederhana sampai basis data SQL yang utuh.
Memilih penyimpanan yang tepat
Ada tiga tingkat, dan memilih yang salah akan menyulitkanmu nanti.
┌──────────────────────────────────────────────────────────────────┐
│ SharedPreferences │
│ Pasangan kunci-nilai sederhana │
│ → tema gelap, sudah pernah onboarding, token, nama pengguna │
│ ❌ Bukan untuk data terstruktur atau banyak │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Berkas (path_provider) │
│ Berkas mentah di direktori aplikasi │
│ → gambar hasil kamera, cache JSON, unduhan │
│ ❌ Tidak bisa dicari atau disaring │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ SQLite (sqflite) │
│ Basis data relasional lengkap │
│ → catatan, transaksi, daftar yang perlu disaring & diurutkan │
│ ❌ Berlebihan untuk sekadar menyimpan satu nilai bool │
└──────────────────────────────────────────────────────────────────┘Aturan praktisnya:
- Satu nilai sederhana? SharedPreferences.
- Berkas biner? path_provider.
- Banyak baris yang perlu dicari, disaring, atau diurutkan? SQLite.
SharedPreferences
Penyimpanan kunci-nilai yang didukung oleh mekanisme asli tiap platform — SharedPreferences di Android, NSUserDefaults di iOS.
yaml
# pubspec.yaml
dependencies:
shared_preferences: ^2.3.3Operasi dasar
dart
import 'package:shared_preferences/shared_preferences.dart';
Future<void> contohPenggunaan() async {
final prefs = await SharedPreferences.getInstance();
// --- Menulis ---
await prefs.setString('nama_pengguna', 'Firnanda');
await prefs.setInt('jumlah_kunjungan', 12);
await prefs.setBool('mode_gelap', true);
await prefs.setDouble('ukuran_font', 16.0);
await prefs.setStringList('riwayat', ['flutter', 'dart', 'bloc']);
// --- Membaca (semuanya nullable) ---
final nama = prefs.getString('nama_pengguna') ?? 'Tamu';
final kunjungan = prefs.getInt('jumlah_kunjungan') ?? 0;
final gelap = prefs.getBool('mode_gelap') ?? false;
final riwayat = prefs.getStringList('riwayat') ?? [];
// --- Menghapus ---
await prefs.remove('nama_pengguna');
await prefs.clear(); // hapus semuanya
// --- Memeriksa ---
final ada = prefs.containsKey('mode_gelap');
}Tipe yang didukung hanya lima: String, int, bool, double, dan List<String>. Tidak ada yang lain.
Selalu sediakan nilai cadangan
Semua metode get* mengembalikan nilai nullable, karena kuncinya mungkin belum pernah ditulis. Selalu pasangkan dengan ??:
dart
final gelap = prefs.getBool('mode_gelap') ?? false;Contoh: menyimpan preferensi tema
dart
// lib/services/preferensi.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class Preferensi {
Preferensi._();
static const _kunciTema = 'mode_tema';
static const _kunciOnboarding = 'sudah_onboarding';
static const _kunciNama = 'nama_pengguna';
static SharedPreferences? _prefs;
/// Panggil sekali di main() sebelum runApp().
static Future<void> inisialisasi() async {
_prefs = await SharedPreferences.getInstance();
}
// --- Mode tema ---
static ThemeMode get modeTema {
final nilai = _prefs?.getString(_kunciTema) ?? 'system';
return switch (nilai) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
static Future<void> setModeTema(ThemeMode mode) async {
await _prefs?.setString(_kunciTema, mode.name);
}
// --- Onboarding ---
static bool get sudahOnboarding =>
_prefs?.getBool(_kunciOnboarding) ?? false;
static Future<void> tandaiOnboardingSelesai() async {
await _prefs?.setBool(_kunciOnboarding, true);
}
// --- Nama pengguna ---
static String get namaPengguna => _prefs?.getString(_kunciNama) ?? 'Tamu';
static Future<void> setNamaPengguna(String nama) async {
await _prefs?.setString(_kunciNama, nama);
}
}Menginisialisasinya di main():
dart
// lib/main.dart
import 'package:flutter/material.dart';
import 'services/preferensi.dart';
void main() async {
// Wajib sebelum memakai plugin apa pun di main()
WidgetsFlutterBinding.ensureInitialized();
await Preferensi.inisialisasi();
runApp(const AplikasiSaya());
}Dan memakainya untuk mengganti tema secara langsung:
dart
class AplikasiSaya extends StatefulWidget {
const AplikasiSaya({super.key});
@override
State<AplikasiSaya> createState() => _AplikasiSayaState();
}
class _AplikasiSayaState extends State<AplikasiSaya> {
late ThemeMode _modeTema;
@override
void initState() {
super.initState();
_modeTema = Preferensi.modeTema; // muat pilihan tersimpan
}
Future<void> _gantiTema(ThemeMode mode) async {
await Preferensi.setModeTema(mode);
setState(() => _modeTema = mode);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: TemaAplikasi.terang(),
darkTheme: TemaAplikasi.gelap(),
themeMode: _modeTema,
home: Beranda(saatGantiTema: _gantiTema),
);
}
}SharedPreferences tidak aman
Data di SharedPreferences disimpan sebagai teks biasa dan bisa dibaca pada perangkat yang di-root. Jangan simpan kata sandi, token autentikasi, atau kunci API di sini. Untuk itu, pakai flutter_secure_storage, yang memanfaatkan Keystore di Android dan Keychain di iOS:
yaml
dependencies:
flutter_secure_storage: ^9.2.2dart
const storage = FlutterSecureStorage();
await storage.write(key: 'token', value: tokenAkses);
final token = await storage.read(key: 'token');path_provider: menyimpan berkas
Ketika kamu punya berkas sungguhan — gambar dari kamera, PDF hasil unduhan, cache JSON — kamu butuh tahu di mana boleh menyimpannya.
yaml
dependencies:
path_provider: ^2.1.5
path: ^1.9.0Direktori yang tersedia
dart
import 'package:path_provider/path_provider.dart';
// Dokumen aplikasi — bertahan, ikut dicadangkan, tidak dihapus OS
final dokumen = await getApplicationDocumentsDirectory();
// Cache — boleh dihapus OS kapan saja saat penyimpanan menipis
final cache = await getTemporaryDirectory();
// Dukungan aplikasi — untuk berkas internal yang tidak dilihat pengguna
final dukungan = await getApplicationSupportDirectory();Pilih dengan sadar:
| Direktori | Gunakan untuk | Risiko |
|---|---|---|
ApplicationDocuments | Data pengguna yang harus bertahan | Ikut memenuhi cadangan iCloud |
Temporary | Cache, berkas sementara | Bisa dihapus OS kapan saja |
ApplicationSupport | Basis data, berkas konfigurasi | Tersembunyi dari pengguna |
Menyimpan gambar dari kamera
Pola yang sangat umum: kamu mengambil foto, dan berkasnya berada di direktori sementara yang bisa dibersihkan sistem. Salin ke tempat permanen.
dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
Future<File> simpanGambarPermanen(File gambarSementara) async {
final direktori = await getApplicationDocumentsDirectory();
// Ambil nama berkasnya saja dari jalur lengkap
final namaBerkas = p.basename(gambarSementara.path);
// Salin ke direktori dokumen
return gambarSementara.copy('${direktori.path}/$namaBerkas');
}Perhatikan paket path diimpor dengan alias p. Ini konvensi umum karena path adalah nama yang terlalu generik dan mudah bentrok.
Membaca dan menulis berkas teks
dart
Future<File> _berkasCatatan() async {
final direktori = await getApplicationDocumentsDirectory();
return File('${direktori.path}/catatan.txt');
}
Future<void> tulisCatatan(String isi) async {
final berkas = await _berkasCatatan();
await berkas.writeAsString(isi);
}
Future<String> bacaCatatan() async {
try {
final berkas = await _berkasCatatan();
return await berkas.readAsString();
} catch (_) {
return ''; // berkas belum ada
}
}Cache JSON
Menyimpan respons API agar aplikasi tetap menampilkan sesuatu saat offline:
dart
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class CacheJson {
Future<File> _berkas(String nama) async {
final dir = await getTemporaryDirectory();
return File('${dir.path}/$nama.json');
}
Future<void> simpan(String nama, Object data) async {
final berkas = await _berkas(nama);
await berkas.writeAsString(json.encode(data));
}
Future<dynamic> muat(String nama) async {
final berkas = await _berkas(nama);
if (!await berkas.exists()) return null;
// Anggap kedaluwarsa setelah 24 jam
final umur = DateTime.now().difference(await berkas.lastModified());
if (umur > const Duration(hours: 24)) return null;
return json.decode(await berkas.readAsString());
}
Future<void> hapus(String nama) async {
final berkas = await _berkas(nama);
if (await berkas.exists()) await berkas.delete();
}
}SQLite dengan sqflite
Ketika kamu butuh menyimpan banyak baris dan bisa mencarinya, kamu butuh basis data sungguhan.
yaml
dependencies:
sqflite: ^2.4.1
path: ^1.9.0Menyiapkan basis data
dart
// lib/services/basis_data.dart
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/tempat.dart';
class BasisData {
BasisData._();
static final BasisData instance = BasisData._();
static Database? _db;
Future<Database> get db async {
if (_db != null) return _db!;
_db = await _buka();
return _db!;
}
Future<Database> _buka() async {
final direktori = await getApplicationDocumentsDirectory();
final jalur = p.join(direktori.path, 'tempat.db');
return openDatabase(
jalur,
version: 1,
onCreate: (db, versi) async {
await db.execute('''
CREATE TABLE tempat (
id TEXT PRIMARY KEY,
judul TEXT NOT NULL,
gambar TEXT NOT NULL,
lintang REAL NOT NULL,
bujur REAL NOT NULL,
alamat TEXT NOT NULL,
dibuat_pada INTEGER NOT NULL
)
''');
// Indeks mempercepat pencarian berdasarkan judul
await db.execute(
'CREATE INDEX idx_tempat_judul ON tempat(judul)',
);
},
onUpgrade: (db, lama, baru) async {
// Dipanggil ketika `version` dinaikkan
if (lama < 2) {
await db.execute('ALTER TABLE tempat ADD COLUMN catatan TEXT');
}
},
);
}
Future<void> tutup() async {
await _db?.close();
_db = null;
}
}Beberapa keputusan penting di sini:
Pola singleton. Basis data dibuka sekali dan dipakai ulang. Membuka koneksi berkali-kali boros dan bisa menyebabkan kunci berkas.
p.join() alih-alih menyambung string. Ini menangani pemisah jalur yang berbeda antar sistem operasi dengan benar.
version dan onUpgrade. Ketika struktur tabelmu berubah, naikkan version dan tulis migrasinya di onUpgrade. Tanpa ini, pengguna yang memperbarui aplikasi akan mengalami crash karena kolom yang tidak ada.
Operasi CRUD
dart
// lib/services/repositori_tempat.dart
import 'package:sqflite/sqflite.dart';
import '../models/tempat.dart';
import 'basis_data.dart';
class RepositoriTempat {
Future<Database> get _db async => BasisData.instance.db;
// --- CREATE ---
Future<void> simpan(Tempat tempat) async {
final db = await _db;
await db.insert(
'tempat',
tempat.toMap(),
// Kalau ID sudah ada, timpa saja
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// --- READ semua ---
Future<List<Tempat>> ambilSemua() async {
final db = await _db;
final baris = await db.query(
'tempat',
orderBy: 'dibuat_pada DESC',
);
return baris.map(Tempat.dariMap).toList();
}
// --- READ satu ---
Future<Tempat?> ambilSatu(String id) async {
final db = await _db;
final baris = await db.query(
'tempat',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (baris.isEmpty) return null;
return Tempat.dariMap(baris.first);
}
// --- READ dengan pencarian ---
Future<List<Tempat>> cari(String kataKunci) async {
final db = await _db;
final baris = await db.query(
'tempat',
where: 'judul LIKE ?',
whereArgs: ['%$kataKunci%'],
orderBy: 'judul ASC',
);
return baris.map(Tempat.dariMap).toList();
}
// --- UPDATE ---
Future<void> perbarui(Tempat tempat) async {
final db = await _db;
await db.update(
'tempat',
tempat.toMap(),
where: 'id = ?',
whereArgs: [tempat.id],
);
}
// --- DELETE ---
Future<void> hapus(String id) async {
final db = await _db;
await db.delete(
'tempat',
where: 'id = ?',
whereArgs: [id],
);
}
Future<int> hitung() async {
final db = await _db;
final hasil = await db.rawQuery('SELECT COUNT(*) AS jumlah FROM tempat');
return Sqflite.firstIntValue(hasil) ?? 0;
}
}Selalu pakai parameter, jangan menyambung string
Ini bukan sekadar gaya — ini keamanan.
dart
// ❌ Rentan SQL injection
where: "judul = '$kataKunci'"
// ✅ Aman, sqflite yang meng-escape nilainya
where: 'judul = ?',
whereArgs: [kataKunci],Kalau kataKunci berisi '; DROP TABLE tempat; --, versi pertama akan menghapus tabelmu. Versi kedua memperlakukannya sebagai teks biasa.
Model dengan toMap dan dariMap
SQLite hanya mengenal int, double, String, Uint8List, dan null. Jadi tipe lain harus dikonversi.
dart
// lib/models/tempat.dart
import 'dart:io';
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
class LokasiTempat {
const LokasiTempat({
required this.lintang,
required this.bujur,
required this.alamat,
});
final double lintang;
final double bujur;
final String alamat;
}
class Tempat {
Tempat({
String? id,
required this.judul,
required this.gambar,
required this.lokasi,
DateTime? dibuatPada,
}) : id = id ?? _uuid.v4(),
dibuatPada = dibuatPada ?? DateTime.now();
final String id;
final String judul;
final File gambar;
final LokasiTempat lokasi;
final DateTime dibuatPada;
/// Ubah menjadi baris basis data.
/// File disimpan sebagai jalurnya, DateTime sebagai milidetik.
Map<String, Object?> toMap() => {
'id': id,
'judul': judul,
'gambar': gambar.path,
'lintang': lokasi.lintang,
'bujur': lokasi.bujur,
'alamat': lokasi.alamat,
'dibuat_pada': dibuatPada.millisecondsSinceEpoch,
};
/// Bangun kembali dari baris basis data.
factory Tempat.dariMap(Map<String, Object?> map) {
return Tempat(
id: map['id'] as String,
judul: map['judul'] as String,
gambar: File(map['gambar'] as String),
lokasi: LokasiTempat(
lintang: map['lintang'] as double,
bujur: map['bujur'] as double,
alamat: map['alamat'] as String,
),
dibuatPada: DateTime.fromMillisecondsSinceEpoch(
map['dibuat_pada'] as int,
),
);
}
}Perhatikan dua konversi yang perlu dilakukan:
File → String. Simpan jalurnya, bukan isinya. Berkas gambarnya sendiri tetap berada di direktori dokumen.
DateTime → int. Menyimpan sebagai milidetik sejak epoch membuat pengurutan berdasarkan waktu bekerja langsung di SQL (ORDER BY dibuat_pada DESC). Menyimpannya sebagai string ISO juga bisa dan lebih mudah dibaca, tetapi pengurutannya bergantung pada format yang konsisten.
Transaksi dan operasi massal
Ketika kamu menyimpan banyak baris sekaligus, satu per satu sangat lambat karena setiap operasi menulis ke disk. Gunakan batch:
dart
Future<void> simpanBanyak(List<Tempat> daftar) async {
final db = await _db;
final batch = db.batch();
for (final tempat in daftar) {
batch.insert(
'tempat',
tempat.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}Untuk operasi yang harus berhasil semua atau tidak sama sekali, gunakan transaksi:
dart
Future<void> pindahkanSaldo(String dariId, String keId, double jumlah) async {
final db = await _db;
await db.transaction((txn) async {
await txn.rawUpdate(
'UPDATE akun SET saldo = saldo - ? WHERE id = ?',
[jumlah, dariId],
);
await txn.rawUpdate(
'UPDATE akun SET saldo = saldo + ? WHERE id = ?',
[jumlah, keId],
);
});
// Kalau salah satu gagal, keduanya dibatalkan
}Contoh lengkap: aplikasi Tempat Favorit
Menggabungkan ketiga teknik: gambar disimpan sebagai berkas, metadata di SQLite, dan preferensi tampilan di SharedPreferences.
dart
// lib/services/layanan_tempat.dart
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/tempat.dart';
import 'repositori_tempat.dart';
class LayananTempat {
final _repo = RepositoriTempat();
/// Menyimpan tempat baru:
/// 1. Salin gambar ke penyimpanan permanen
/// 2. Simpan metadata ke basis data
Future<Tempat> tambah({
required String judul,
required File gambarSementara,
required LokasiTempat lokasi,
}) async {
final direktori = await getApplicationDocumentsDirectory();
final namaBerkas = p.basename(gambarSementara.path);
final gambarPermanen =
await gambarSementara.copy(p.join(direktori.path, namaBerkas));
final tempat = Tempat(
judul: judul,
gambar: gambarPermanen,
lokasi: lokasi,
);
await _repo.simpan(tempat);
return tempat;
}
Future<List<Tempat>> muatSemua() => _repo.ambilSemua();
Future<List<Tempat>> cari(String kataKunci) =>
kataKunci.trim().isEmpty ? _repo.ambilSemua() : _repo.cari(kataKunci);
/// Menghapus tempat: hapus baris DAN berkas gambarnya,
/// supaya tidak ada berkas yatim yang memenuhi penyimpanan.
Future<void> hapus(Tempat tempat) async {
await _repo.hapus(tempat.id);
if (await tempat.gambar.exists()) {
await tempat.gambar.delete();
}
}
}Detail terakhir itu — menghapus berkas gambar ketika barisnya dihapus — sering terlupakan. Tanpa itu, penyimpanan pengguna terus terisi berkas yang tidak lagi dirujuk oleh apa pun.
Layar yang memakainya:
dart
// lib/screens/daftar_tempat.dart
import 'package:flutter/material.dart';
import '../models/tempat.dart';
import '../services/layanan_tempat.dart';
class DaftarTempat extends StatefulWidget {
const DaftarTempat({super.key});
@override
State<DaftarTempat> createState() => _DaftarTempatState();
}
class _DaftarTempatState extends State<DaftarTempat> {
final _layanan = LayananTempat();
late Future<List<Tempat>> _futureTempat;
@override
void initState() {
super.initState();
_futureTempat = _layanan.muatSemua();
}
void _muatUlang() {
setState(() {
_futureTempat = _layanan.muatSemua();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tempat Favorit'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (ctx) => const TambahTempat()),
);
_muatUlang();
},
),
],
),
body: FutureBuilder<List<Tempat>>(
future: _futureTempat,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final daftar = snapshot.data ?? [];
if (daftar.isEmpty) {
return const Center(
child: Text('Belum ada tempat. Tambahkan yang pertama!'),
);
}
return ListView.builder(
itemCount: daftar.length,
itemBuilder: (ctx, i) {
final tempat = daftar[i];
return ListTile(
leading: CircleAvatar(
radius: 26,
backgroundImage: FileImage(tempat.gambar),
),
title: Text(tempat.judul),
subtitle: Text(
tempat.lokasi.alamat,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () async {
await _layanan.hapus(tempat);
_muatUlang();
},
),
);
},
);
},
),
);
}
}FileImage adalah yang kamu pakai untuk menampilkan gambar dari berkas lokal — berbeda dari NetworkImage dan AssetImage.
Alternatif modern
SQLite bukan satu-satunya pilihan. Dua paket berikut sering dipakai di proyek baru:
Hive / Isar — basis data NoSQL yang sangat cepat, tanpa SQL, dengan API yang lebih sederhana. Cocok kalau datamu tidak butuh relasi rumit.
Drift — lapisan di atas SQLite yang menghasilkan kode Dart bertipe aman dari definisi tabelmu. Kamu menulis kueri dalam Dart, dan compiler menangkap kesalahannya. Pilihan yang baik untuk proyek besar.
Untuk belajar, sqflite tetap layak dikuasai karena ia mengajarkan SQL sungguhan — pengetahuan yang berpindah ke mana-mana.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Pencatat Kebiasaan — ⭐⭐ · 45–60 menit
Tantangan: Buat pencatat kebiasaan harian yang menyimpan datanya di SQLite: nama kebiasaan, dan satu baris centang per hari. Tambahkan satu pengaturan sederhana (misalnya jam pengingat) yang disimpan di SharedPreferences — sehingga kamu merasakan langsung kapan memakai yang mana.
Bayangan tampilan:
text
╭───────────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Minum Air [☑][☑][☐][☑][☐][☐][☑] │
│ Olahraga [☑][☐][☐][☑][☑][☐][☐] │
│ Baca Buku [☐][☑][☑][☑][☐][☑][☑] │
│ Sen Sel Rab Kam Jum Sab Min│
│ ▬▬▬▬ │
╰───────────────────────────────────────╯Satu baris = satu kebiasaan (di SQLite, karena akan dicari/disaring). Jam pengingat di pengaturan = satu nilai tunggal (di SharedPreferences).
Kriteria selesai:
- Basis data dibuka sekali lewat pola singleton, dengan jalur yang dibangun memakai
p.join(). - Setiap kueri yang menyertakan nilai memakai
whereArgs. Tidak ada penyambungan string ke dalam SQL, satu pun. - Tanggal disimpan sebagai
millisecondsSinceEpoch, bukan sebagai teks yang diformat. - Setelah aplikasi berjalan, tambahkan satu kolom baru ke tabel, naikkan
version, dan tulisonUpgrade— lalu buktikan data lama tetap utuh dengan memasang ulang tanpa menghapus aplikasi.
Petunjuk: Kriteria terakhir adalah yang paling berharga dan paling sering dilewati orang. Cara mengujinya: jalankan aplikasi dengan skema lama, isi beberapa data, hentikan aplikasi, ubah kodenya, lalu jalankan lagi tanpa menghapus aplikasi dari perangkat. Kalau kamu lupa menaikkan version, onUpgrade tidak pernah dipanggil dan kamu akan mendapat error "no such column" — yang justru bukti bahwa pengujianmu bekerja. Untuk memutuskan penyimpanan mana yang dipakai, aturannya sederhana: kalau kamu suatu saat ingin mencari, menyaring, atau mengurutkan datanya, itu SQLite. Kalau ia hanya satu nilai yang dibaca saat aplikasi dibuka, itu SharedPreferences.
Variasi 2: Buku Alamat yang Bisa Dicari — ⭐⭐ · 30–45 menit
Tantangan: Bangun buku alamat berisi minimal dua ratus kontak di SQLite, dengan pencarian nama dan nomor telepon yang dijalankan oleh basis data, bukan dengan memuat semua kontak ke memori lalu menyaringnya di Dart.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ 🔍 [budi] │
│ ┌─────────────────────────────┐ │
│ │ Budi Santoso 0812-xxx-111 │ │
│ │ Budi Hartono 0813-xxx-222 │ │
│ │ Nur Budianto 0821-xxx-333 │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Tiga hasil di atas cocok meski "budi" muncul di awal, tengah, atau sebagai bagian nama — dan pencarian ini dijalankan lewat WHERE ... LIKE ? di dalam SQLite, bukan .where() di Dart atas 200+ kontak yang sudah dimuat semua ke memori.
Kriteria selesai:
- Pencarian memakai klausa
WHERE ... LIKE ?denganwhereArgs, dan mengembalikan hasil untuk pencocokan sebagian di tengah nama. - Ada indeks pada kolom yang dicari, dan kamu bisa menjelaskan kenapa.
- Daftar hasil dibatasi jumlahnya per kueri, bukan mengambil seluruh kecocokan sekaligus.
- Menyaring dengan kata kunci yang mengandung tanda kutip tunggal tidak merusak apa pun.
Petunjuk: Kriteria terakhir adalah demonstrasi langsung kenapa whereArgs wajib: nama seperti "O'Brien" akan mematahkan SQL yang dirakit dengan penyambungan string, dan kalau nama bisa mematahkannya, masukan yang disengaja bisa melakukan hal yang jauh lebih buruk. Untuk indeks, ingat bahwa ia mempercepat pembacaan dengan biaya sedikit memperlambat penulisan dan menambah ukuran berkas — pertukaran yang hampir selalu menguntungkan untuk kolom yang sering dicari.
Variasi 3: Arsip Dokumen — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun penyimpan dokumen: pengguna memilih berkas, aplikasi menyalinnya ke penyimpanan aplikasi, mencatat metadatanya (nama, ukuran, tanggal, jalur) ke SQLite, dan bisa membukanya kembali kapan saja. Menghapus entri harus menghapus berkasnya juga.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Total: 45.2 MB dari 12 dokumen │
│ ┌─────────────────────────────┐ │
│ │ 📄 Laporan.pdf 2.1 MB │ │
│ │ 📄 Kontrak.docx 0.8 MB │ │
│ │ 📄 Foto-KTP.jpg 3.4 MB [🗑]│ │
│ └─────────────────────────────┘ │
│ [+ Tambah Dokumen] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Berkas disimpan di direktori dokumen, bukan direktori sementara, dan kamu bisa menjelaskan bedanya.
- Basis data menyimpan jalur, bukan isi berkasnya.
- Menghapus entri menghapus berkasnya, dan berkas yang sudah hilang duluan tidak membuat penghapusan gagal.
- Aplikasi menampilkan total ruang yang dipakai seluruh dokumen.
Petunjuk: Berkas yatim — entri basis data yang menunjuk berkas yang sudah tidak ada, atau berkas yang tidak punya entri — adalah masalah klasik penyimpanan berpasangan seperti ini. Tangani keduanya: hapus berkas sebelum menghapus barisnya, dan tulis satu fungsi pembersih yang bisa memindai keduanya dan melaporkan ketidakcocokan. Ingat juga bahwa jalur absolut direktori aplikasi bisa berubah antar pemasangan di iOS, jadi menyimpan nama berkas relatif lebih aman daripada jalur penuh.
Variasi 4: Migrasi Bertingkat — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Bangun basis data versi 1 dengan satu tabel sederhana, isi datanya, lalu kembangkan sampai versi 4 lewat tiga migrasi terpisah: menambah kolom, memecah satu tabel menjadi dua dengan memindahkan datanya, dan mengubah tipe sebuah kolom. Pengguna dari versi berapa pun harus bisa naik ke versi 4 tanpa kehilangan data.
Kriteria selesai:
onUpgrademenangani lompatan dari versi 1 langsung ke 4, bukan hanya dari 3 ke 4.- Migrasi yang memecah tabel memindahkan seluruh data lama dengan benar.
- Seluruh migrasi berjalan di dalam transaksi, sehingga kegagalan di tengah tidak meninggalkan basis data setengah jadi.
- Kamu sudah menguji ketiga jalur: 1→4, 2→4, dan 3→4.
Petunjuk: Pola yang benar untuk onUpgrade adalah rangkaian if yang tidak saling eksklusif — jalankan migrasi ke 2 kalau versi lama di bawah 2, lalu migrasi ke 3 kalau di bawah 3, dan seterusnya. Dengan begitu lompatan jauh tertangani otomatis. SQLite tidak mendukung mengubah tipe kolom secara langsung, jadi migrasi ketiga butuh pola buat-tabel-baru, salin, hapus lama, ganti nama.
Variasi 5: Impor Massal — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Bangun fitur impor yang memasukkan lima ribu baris data ke SQLite dari satu berkas. Ukur waktunya dengan tiga pendekatan: penyisipan satu per satu, batch(), dan transaction() — lalu tulis kesimpulanmu sendiri.
Bayangan tampilan (perbandingan tiga pendekatan):
text
Satu per satu batch() transaction()
┌──────────┐ ┌──────────┐ ┌──────────┐
│▓░░░░░░░░░│ 12% │▓▓▓▓▓▓░░░░│ 68% │▓▓▓▓▓▓▓▓▓▓│100%
│ ~45 detik │ │ ~3 detik │ │ ~1 detik │
└──────────┘ └──────────┘ └──────────┘
paling lambat cepat tercepatKriteria selesai:
- Ketiga pendekatan diukur, dan kamu bisa menyebut selisih waktunya dalam angka.
- Impor menampilkan progres yang benar-benar bergerak, bukan indikator yang berputar tanpa informasi.
- Impor yang gagal di tengah tidak meninggalkan sebagian data masuk.
- Antarmuka tetap responsif selama impor berjalan.
Petunjuk: Selisihnya akan jauh lebih besar dari yang kamu duga — biasanya puluhan kali lipat — karena setiap penyisipan tunggal memicu operasi tulis ke disk tersendiri. Untuk kriteria ketiga, transaction() memberimu sifat semua-atau-tidak-sama-sekali secara gratis. Dan untuk antarmuka yang tetap responsif dengan progres yang bergerak, pecah pekerjaan menjadi beberapa kelompok dan beri kesempatan bagi Flutter menggambar di antara kelompok.
Ikhtisar
- SharedPreferences untuk nilai sederhana (tema, flag onboarding), path_provider untuk berkas, sqflite untuk data yang perlu dicari dan disaring.
- SharedPreferences hanya mendukung lima tipe dan semua
get*mengembalikan nullable — selalu pasangkan dengan??. - Panggil
WidgetsFlutterBinding.ensureInitialized()sebelum memakai plugin apa pun di dalammain(). - Jangan simpan token atau kata sandi di SharedPreferences. Pakai
flutter_secure_storage. getTemporaryDirectory()bisa dibersihkan sistem kapan saja — jangan taruh data penting di sana. PakaigetApplicationDocumentsDirectory().- Salin gambar hasil kamera dari direktori sementara ke direktori dokumen agar tidak hilang.
- Buka basis data sekali dengan pola singleton, dan pakai
p.join()untuk membangun jalurnya. - Naikkan
versiondan tulisonUpgradesetiap kali struktur tabel berubah, atau pengguna lama akan mengalami crash. - Selalu pakai
whereArgs, jangan pernah menyambung string ke dalam SQL — ini melindungi dari SQL injection. - SQLite hanya mengenal
int,double,String,Uint8List,null. SimpanDateTimesebagaimillisecondsSinceEpochdanFilesebagai jalurnya. - Gunakan
batch()untuk banyak penyisipan,transaction()untuk operasi yang harus berhasil bersama. - Ketika menghapus baris yang merujuk berkas, hapus berkasnya juga agar tidak ada berkas yatim.
Berikutnya: Bab 16 — Fitur Native Perangkat, tempat kita memakai kamera, lokasi, dan peta.
Transkrip asli
Disintesis dari 1_flutter_core/12_fav-place-app_using-native-device-feature.md (video 27–29: Installing Packages for Local Storage, Storing the Picked Image Locally, Storing Place Data in a SQL Database) dan 1_flutter_zero-to-hero/9_package-management-and-modifications.md (Shared Pref). Lihat PDF Flutter Core dan PDF Zero to Hero.